From 8c6aef1e12e7d5f891dd4da2b44a40b68175b4e3 Mon Sep 17 00:00:00 2001 From: Kev Date: Thu, 27 Aug 2026 16:50:50 -0400 Subject: [PATCH] Event admission gate: an unresolved or contradicted game does not earn a Read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fractured identity keeps two bad records from merging. It does not make an unknown game true. Until now a prop with an unresolved or verified-impossible event still continued into grading under that synthetic key; it no longer does. - event_binding_status contract: RESOLVED / UNRESOLVED / AMBIGUOUS / CONTRADICTED / UNSUPPORTED. CONTRADICTED and UNRESOLVED stay distinct — one means we know the association is wrong, the other that we do not know. - ONE admission gate (gradeSlateService.admitForGrading), before dedupe and before grading. Admission requires RESOLVED *and* a canonical_event_id; a legacy derived game_id can never satisfy it. Rejected props are returned, not discarded, so retention keeps them as evidence. - Roster evidence is now DATE-SCOPED. statsapi honours ?date= and it changes the answer (Joe Mack is on the 2026-08-26 Marlins roster, absent on 2026-04-15). Evidence that does not describe the slate's date can only yield UNRESOLVED, never CONTRADICTED — uncertainty must not become an accusation. - A mis-nested market is REFUSED, never re-bound. Knowing Joe Mack is a Marlin does not license moving a provider record into the Marlins game; that would invent provenance. Measured on the real 2026-08-26 slate: 2,878 props -> 2,874 admitted, 4 rejected (EVENT_PLAYER_TEAM_CONTRADICTION), each player's correct game still resolving. Doubleheader 824514/824478 both remain independently RESOLVED and admitted. No change to probability, projection, side, grade, confidence, ranking, normalization or calibration. Lineage remains disabled. Suite 380/5,061/0 from the release worktree; web tsc exit 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8 --- scripts/dedupe-parity.js | 15 +- src/services/adapters/mlbStatsAdapter.js | 10 +- src/services/event/eventIdentity.js | 85 ++++++++-- src/services/gradeSlateService.js | 60 ++++++- src/services/snapshotService.js | 19 ++- tests/unit/eventPublication.test.js | 190 +++++++++++++++++++++++ 6 files changed, 357 insertions(+), 22 deletions(-) diff --git a/scripts/dedupe-parity.js b/scripts/dedupe-parity.js index 22a7fb0..3b4ea87 100644 --- a/scripts/dedupe-parity.js +++ b/scripts/dedupe-parity.js @@ -65,12 +65,15 @@ async function main() { // PARTICIPANT EVIDENCE — the same index production builds. const built = await E.buildPlayerTeamIndex(games, { - getTeamRoster: async (id) => { - const r = await (await fetch(`https://statsapi.mlb.com/api/v1/teams/${id}/roster?rosterType=active`)).json(); + asOfDate: DATE, + getTeamRoster: async (id, asOf) => { + const d = asOf ? `&date=${asOf}` : ''; + const r = await (await fetch(`https://statsapi.mlb.com/api/v1/teams/${id}/roster?rosterType=active${d}`)).json(); return (r.roster || []).map((x) => ({ id: x.person && x.person.id, name: x.person && x.person.fullName })); }, }); const playerTeams = built.index; + const evidenceDateValid = E.evidenceIsDateValid(built.stats, DATE); // Reconstruct prop shapes. One row per (player, stat, line, book) — the side // is not part of the dedupe key, so both sides collapse to the same prop. @@ -92,7 +95,7 @@ async function main() { } // The schedule has one game per pair on a non-doubleheader date, so teams // alone resolve; no game_time is needed and none is reconstructed. - const ident = E.attachEventIdentity(SPORT, props, games, playerTeams); + const ident = E.attachEventIdentity(SPORT, props, games, playerTeams, evidenceDateValid); const oldSet = new Set(props.map(oldKey)); const newSet = new Set(props.map(newKey)); @@ -112,7 +115,11 @@ async function main() { console.log(`participant evidence : ${built.stats.players} players / ${built.stats.teams} rosters`); console.log(`canonical event resolution : ${ident.canonical}/${ident.total}` + ` (unresolved ${ident.unresolved}, unsupported ${ident.unsupported})`); - console.log(`IMPOSSIBLE-BINDING refusals : ${ident.impossible}`); + console.log(`evidence date-valid : ${evidenceDateValid} (as of ${DATE})`); + console.log(`CONTRADICTED bindings : ${ident.impossible}`); + const gate = require('../src/services/gradeSlateService').__internals.admitForGrading(props, SPORT); + console.log(`ADMITTED to grading : ${gate.admitted.length}`); + console.log(`REJECTED before grading : ${gate.rejected.length} ${JSON.stringify(gate.reasons)}`); if (Object.keys(ident.reasons).length) console.log(` unresolved reasons : ${JSON.stringify(ident.reasons)}`); console.log(`distinct events : ${new Set(props.map((p) => p.canonical_event_id).filter(Boolean)).size}`); console.log(''); diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js index 81cf4a9..c404e0e 100644 --- a/src/services/adapters/mlbStatsAdapter.js +++ b/src/services/adapters/mlbStatsAdapter.js @@ -427,10 +427,14 @@ async function resolveTeam(abbr, season = DEFAULT_SEASON) { * Active roster for a team id (Session 51) → [{ id, name, position, jersey }]. * Cached 6h. [] on failure. */ -async function getTeamRoster(teamId, season = DEFAULT_SEASON) { +async function getTeamRoster(teamId, season = DEFAULT_SEASON, asOfDate = null) { if (!teamId) return []; - const url = `${BASE}/teams/${teamId}/roster?rosterType=active&season=${season}`; - const data = await fetchWithCache(url, `mlbstats:roster:${teamId}:${season}`, 6 * 3600); + // `date` scopes the roster to a MOMENT. Without it the answer is "today", + // which is the wrong evidence for judging any other slate date. Verified: + // Joe Mack appears on the 2026-08-26 Marlins roster and not on 2026-04-15. + const d = asOfDate ? `&date=${String(asOfDate).slice(0, 10)}` : ''; + const url = `${BASE}/teams/${teamId}/roster?rosterType=active&season=${season}${d}`; + const data = await fetchWithCache(url, `mlbstats:roster:${teamId}:${season}:${asOfDate || 'now'}`, 6 * 3600); const roster = (data && Array.isArray(data.roster)) ? data.roster : []; return roster.map((r) => ({ id: r.person?.id ?? null, diff --git a/src/services/event/eventIdentity.js b/src/services/event/eventIdentity.js index 0f50406..115b6d9 100644 --- a/src/services/event/eventIdentity.js +++ b/src/services/event/eventIdentity.js @@ -49,12 +49,42 @@ const IDENTITY_METHOD = Object.freeze({ const SUPPORTED = Object.freeze(['mlb']); +/** + * -- EVENT BINDING STATUS -------------------------------------------------- + * The normalised admission contract. `event_identity_method` says HOW we tried; + * this says WHETHER the result may be used. + * + * RESOLVED a canonical event was established -> may grade + * UNRESOLVED we could not establish one -> diagnostics only + * AMBIGUOUS several events fit, none provable -> diagnostics only + * CONTRADICTED verified player/event conflict -> diagnostics only + * UNSUPPORTED the sport has no resolver -> unchanged legacy path + * + * CONTRADICTED and UNRESOLVED are deliberately distinct. One means we know the + * association is wrong; the other means we do not know. Collapsing them would + * let a data gap masquerade as a detected lie. + */ +const BINDING_STATUS = Object.freeze({ + RESOLVED: 'RESOLVED', + UNRESOLVED: 'UNRESOLVED', + AMBIGUOUS: 'AMBIGUOUS', + CONTRADICTED: 'CONTRADICTED', + UNSUPPORTED: 'UNSUPPORTED', +}); + /** Namespaced so two sports' numeric ids can never collide. */ function canonicalEventId(sport, nativeId) { if (!sport || nativeId === null || nativeId === undefined || nativeId === '') return null; return `${String(sport).toLowerCase()}:gamepk:${nativeId}`; } +const STATUS_FOR_REASON = Object.freeze({ + ambiguous_no_game_time: BINDING_STATUS.AMBIGUOUS, + ambiguous_no_schedule_time: BINDING_STATUS.AMBIGUOUS, + ambiguous_time_too_far: BINDING_STATUS.AMBIGUOUS, + impossible_binding: BINDING_STATUS.CONTRADICTED, +}); + const refusal = (method, reason) => Object.freeze({ canonical_event_id: null, event_identity_source: IDENTITY_SOURCE.UNKNOWN, @@ -62,6 +92,9 @@ const refusal = (method, reason) => Object.freeze({ event_identity_version: IDENTITY_VERSION, event_occurrence: null, reason: reason || null, + event_binding_status: method === IDENTITY_METHOD.UNSUPPORTED_SPORT + ? BINDING_STATUS.UNSUPPORTED + : (STATUS_FOR_REASON[reason] || BINDING_STATUS.UNRESOLVED), }); const norm = (v) => String(v || '').toLowerCase().replace(/[^a-z]/g, ''); @@ -187,8 +220,9 @@ function checkParticipantEvidence(prop, game, playerTeams) { async function buildPlayerTeamIndex(games, deps) { const d = deps || {}; const getRoster = d.getTeamRoster; + const asOfDate = d.asOfDate || null; const index = new Map(); - const out = { teams: 0, players: 0, failed: 0 }; + const out = { teams: 0, players: 0, failed: 0, as_of_date: asOfDate, date_scoped: false }; if (typeof getRoster !== 'function') return { index, stats: out }; const ids = new Map(); @@ -200,7 +234,11 @@ async function buildPlayerTeamIndex(games, deps) { } for (const [id, name] of ids) { let roster = null; - try { roster = await getRoster(id); } catch { roster = null; } + // asOfDate is passed through so the roster describes the SLATE'S DATE, not + // "now". Verified against statsapi: Joe Mack is on the Marlins roster for + // 2026-08-26 and absent from 2026-04-15, so the parameter genuinely changes + // the answer rather than being decorative. + try { roster = await getRoster(id, asOfDate); } catch { roster = null; } if (!Array.isArray(roster)) { out.failed += 1; continue; } out.teams += 1; const abbr = teamAbbr(name); @@ -213,8 +251,28 @@ async function buildPlayerTeamIndex(games, deps) { out.players += 1; } } + out.date_scoped = Boolean(asOfDate) && out.teams > 0; return { index, stats: out }; } + +/** + * -- TEMPORAL VALIDITY OF PARTICIPANT EVIDENCE --------------------------- + * A roster is a statement about a MOMENT. Using today's roster to judge a + * materially older slate would reject props on evidence that describes the + * wrong time — a player traded last week was not on that team in April. + * + * So evidence may CONTRADICT only when it is date-scoped to the slate being + * judged. Otherwise the strongest honest answer is UNRESOLVED: we could not + * establish the association, not "we proved it wrong". + * + * `evidenceIsDateValid` is what separates those two verdicts. + */ +function evidenceIsDateValid(stats, gameDate) { + if (!stats || !gameDate) return false; + if (!stats.date_scoped) return false; + return String(stats.as_of_date) === String(gameDate); +} + /** * Resolve ONE prop to a canonical MLB event. * @@ -228,7 +286,7 @@ async function buildPlayerTeamIndex(games, deps) { * @param {object} prop needs home_team, away_team, optionally game_time * @param {Array} games mlbStatsAdapter.getScheduleWithPitchers() output */ -function resolveMlbEvent(prop, games, playerTeams) { +function resolveMlbEvent(prop, games, playerTeams, evidenceDateValid) { const p = prop || {}; const list = Array.isArray(games) ? games.filter((g) => g && g.gamePk != null) : []; if (list.length === 0) return refusal(IDENTITY_METHOD.UNRESOLVED, 'no_schedule'); @@ -242,6 +300,11 @@ function resolveMlbEvent(prop, games, playerTeams) { const possible = matches.filter((g) => !checkParticipantEvidence(prop, g, playerTeams)); if (possible.length === 0) { const ev = checkParticipantEvidence(prop, matches[0], playerTeams); + // Evidence that does not describe THIS date cannot prove a contradiction. + // Uncertainty must not be promoted into an accusation. + if (evidenceDateValid === false) { + return refusal(IDENTITY_METHOD.UNRESOLVED, 'participant_evidence_not_date_valid'); + } return Object.freeze({ canonical_event_id: null, event_identity_source: IDENTITY_SOURCE.UNKNOWN, @@ -249,6 +312,7 @@ function resolveMlbEvent(prop, games, playerTeams) { event_identity_version: IDENTITY_VERSION, event_occurrence: null, reason: 'impossible_binding', + event_binding_status: BINDING_STATUS.CONTRADICTED, evidence: ev || null, }); } @@ -284,6 +348,7 @@ function resolveMlbEvent(prop, games, playerTeams) { // Self-describing occurrence, straight from the source. Never inferred. event_occurrence: chosen.gameNumber ?? null, reason: null, + event_binding_status: BINDING_STATUS.RESOLVED, }); } @@ -291,28 +356,30 @@ function resolveMlbEvent(prop, games, playerTeams) { * Sport-neutral entry point. Only MLB resolves; everything else is * UNSUPPORTED_SPORT, which is a truthful state and not a failure. */ -function resolveEvent(sport, prop, games, playerTeams) { +function resolveEvent(sport, prop, games, playerTeams, evidenceDateValid) { const sp = String(sport || '').toLowerCase(); if (!SUPPORTED.includes(sp)) return refusal(IDENTITY_METHOD.UNSUPPORTED_SPORT, `sport_${sp || 'unknown'}`); - return resolveMlbEvent(prop, games, playerTeams); + return resolveMlbEvent(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. */ -function attachEventIdentity(sport, props, games, playerTeams) { +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: {} }; for (const p of list) { if (!p) continue; - const r = resolveEvent(sport, p, games, playerTeams); + const r = resolveEvent(sport, p, games, playerTeams, evidenceDateValid); // Declared on EVERY prop, resolved or not. p.canonical_event_id = r.canonical_event_id; p.event_identity_source = r.event_identity_source; p.event_identity_method = r.event_identity_method; p.event_identity_version = r.event_identity_version; p.event_occurrence = r.event_occurrence; + p.event_binding_status = r.event_binding_status; + p.event_binding_reason = r.reason || null; if (r.event_identity_method === IDENTITY_METHOD.CANONICAL) out.canonical += 1; else if (r.event_identity_method === IDENTITY_METHOD.UNSUPPORTED_SPORT) out.unsupported += 1; else { @@ -326,6 +393,6 @@ function attachEventIdentity(sport, props, games, playerTeams) { module.exports = { resolveEvent, resolveMlbEvent, attachEventIdentity, canonicalEventId, teamsMatch, teamAbbr, - checkParticipantEvidence, buildPlayerTeamIndex, - IDENTITY_SOURCE, IDENTITY_METHOD, IDENTITY_VERSION, SUPPORTED, + 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 ee7f160..fb00f90 100644 --- a/src/services/gradeSlateService.js +++ b/src/services/gradeSlateService.js @@ -77,6 +77,55 @@ const DEFAULT_TTL = 7200; // 2 hours — matches the spec's grades-cache TTL. // and before the limit — which makes the graded set byte-identical to what it // was before the widening. This gate lifts only when the MLB calibration is // re-run on the consensus ruler and v2 is promoted. +/** + * -- EVENT ADMISSION GATE ------------------------------------------------ + * THE single seam where an MLB prop earns the right to be graded. + * + * A fractured identity keeps two bad records from merging. It does NOT make an + * unknown game true. A VYNDR Read depends on an event world — participant, + * opponent, role, game — so a prop whose game cannot be established has no + * trustworthy context to grade against, and one whose game is verifiably wrong + * has worse than none. + * + * RESOLVED + canonical_event_id -> admitted + * UNRESOLVED / AMBIGUOUS -> rejected, diagnostics only + * CONTRADICTED -> rejected, diagnostics only + * UNSUPPORTED (no resolver) -> admitted; unchanged legacy path + * + * A legacy derived game_id can NEVER satisfy this gate — separating two real + * games is exactly what it cannot do. + * + * Rejected props are RETURNED, not discarded, so retention can keep them as + * evidence. They simply never become a graded candidate, a selected Read, a + * Redis Read, or a Ledger Read. + * + * This is the ONLY admission check. It sits before dedupe and before grading — + * the earliest point where identity is known and the latest before cost is + * incurred. + */ +function admitForGrading(props, sport) { + const list = Array.isArray(props) ? props : []; + const admitted = []; + const rejected = []; + const reasons = {}; + for (const p of list) { + if (!p) continue; + const status = p.event_binding_status; + // A sport with no canonical resolver is unchanged: it never had canonical + // identity, and gating it would delete its slate for no safety gain. + if (!status || status === 'UNSUPPORTED') { admitted.push(p); continue; } + if (status === 'RESOLVED' && p.canonical_event_id) { admitted.push(p); continue; } + const why = status === 'CONTRADICTED' + ? 'EVENT_PLAYER_TEAM_CONTRADICTION' + : (status === 'AMBIGUOUS' ? 'EVENT_AMBIGUOUS' : 'EVENT_UNRESOLVED'); + p.event_admission = 'REJECTED'; + p.event_rejection_reason = why; + reasons[why] = (reasons[why] || 0) + 1; + rejected.push(p); + } + return { admitted, rejected, reasons, sport: String(sport || '').toLowerCase() }; +} + /** * WHAT DUPLICATES IS THIS FUNCTION SUPPOSED TO REMOVE? * @@ -300,7 +349,14 @@ async function gradeAndCacheSlate(sport, props, opts = {}) { } try { - const unique = dedupeProps(props, limit); + // ADMISSION BEFORE DEDUPE: a prop that cannot name its game never reaches + // the grader, and a contradicted one never reaches it either. + const gate = admitForGrading(props, sport); + if (gate.rejected.length > 0) { + console.log(`[grade-slate] ${sport} event admission: ${gate.admitted.length} admitted, ` + + `${gate.rejected.length} rejected ${JSON.stringify(gate.reasons)}`); + } + const unique = dedupeProps(gate.admitted, limit); if (unique.length === 0) return { written: false, count: 0 }; const graded = (await mapLimit(unique, concurrency, (p) => gradeBestSide(grade, p, sport, opts))) @@ -321,5 +377,5 @@ async function gradeAndCacheSlate(sport, props, opts = {}) { module.exports = { gradeAndCacheSlate, - __internals: { dedupeProps, gradeBestSide, mapLimit, DEFAULT_LIMIT, DEFAULT_TTL, DEFAULT_CONCURRENCY }, + __internals: { dedupeProps, admitForGrading, gradeBestSide, mapLimit, DEFAULT_LIMIT, DEFAULT_TTL, DEFAULT_CONCURRENCY }, }; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index c7ed16e..779aa51 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -456,20 +456,31 @@ async function runSnapshot(sport, opts = {}) { // ARE the wrong event's teams. The player's real team has to come from // a source that cannot be wrong the same way — the slate's rosters. // Best-effort: no index means no refusals, i.e. the previous behaviour. + // The roster is fetched AS OF THE SLATE'S DATE, not "now". statsapi + // honours a `date` parameter and it genuinely changes the answer, so + // the evidence describes the moment being judged. + const slateDate = dates.length === 1 ? dates[0] : null; let playerTeams = null; + let evidenceDateValid = false; try { const built = await evid.buildPlayerTeamIndex(games, { - getTeamRoster: (id) => mlbAdapter.getTeamRoster(id), + getTeamRoster: (id, asOf) => mlbAdapter.getTeamRoster(id, undefined, asOf), + asOfDate: slateDate, }); playerTeams = built.index; + evidenceDateValid = evid.evidenceIsDateValid(built.stats, slateDate); console.log(`[snapshot] participant evidence ${sp}: ${built.stats.players} players` - + ` across ${built.stats.teams} rosters${built.stats.failed ? `, ${built.stats.failed} unreadable` : ''}`); + + ` across ${built.stats.teams} rosters as of ${slateDate || 'unscoped'}` + + `${built.stats.failed ? `, ${built.stats.failed} unreadable` : ''}` + + ` · date-valid=${evidenceDateValid}`); } catch (e3) { console.warn(`[snapshot] participant evidence unavailable for ${sp}:`, e3.message); } - const e = evid.attachEventIdentity(sp, props, games, playerTeams); + // 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); console.log(`[snapshot] event identity ${sp}: ${e.canonical}/${e.total} canonical, ` - + `${e.unresolved} unresolved, ${e.impossible} impossible-binding refusals` + + `${e.unresolved} unresolved, ${e.impossible} contradicted` + `${Object.keys(e.reasons).length ? ' ' + JSON.stringify(e.reasons) : ''}`); } catch (e2) { // Identity is additive: a failure leaves props on legacy identity, the diff --git a/tests/unit/eventPublication.test.js b/tests/unit/eventPublication.test.js index d3cfc8a..5cdb99e 100644 --- a/tests/unit/eventPublication.test.js +++ b/tests/unit/eventPublication.test.js @@ -732,3 +732,193 @@ describe('IMPOSSIBLE-BINDING INVARIANT — the two verified regressions', () => expect(built.index.size).toBe(0); }); }); + +describe('EVENT ADMISSION — a fractured identity is not model admission', () => { + const gs = require('../../src/services/gradeSlateService'); + const admit = (props) => gs.__internals.admitForGrading(props, 'mlb'); + const prop = (o) => ({ player: 'P', stat_type: 'hits', line: 0.5, book: 'draftkings', ...o }); + + test('RESOLVED with a canonical id is admitted', () => { + const r = admit([prop({ event_binding_status: 'RESOLVED', canonical_event_id: 'mlb:gamepk:1' })]); + expect(r.admitted).toHaveLength(1); + expect(r.rejected).toHaveLength(0); + }); + + test('RESOLVED WITHOUT a canonical id is still rejected', () => { + // The status alone is not the credential; the id has to be there. + const r = admit([prop({ event_binding_status: 'RESOLVED' })]); + expect(r.admitted).toHaveLength(0); + expect(r.rejected).toHaveLength(1); + }); + + test.each([ + ['UNRESOLVED', 'EVENT_UNRESOLVED'], + ['AMBIGUOUS', 'EVENT_AMBIGUOUS'], + ['CONTRADICTED', 'EVENT_PLAYER_TEAM_CONTRADICTION'], + ])('%s does NOT grade and is labelled %s', (status, reason) => { + const r = admit([prop({ event_binding_status: status })]); + expect(r.admitted).toHaveLength(0); + expect(r.rejected[0].event_rejection_reason).toBe(reason); + expect(r.rejected[0].event_admission).toBe('REJECTED'); + }); + + test('a legacy derived game_id can never satisfy the gate', () => { + const r = admit([prop({ event_binding_status: 'UNRESOLVED', game_id: 'mlb:2026-08-26:A@B' })]); + expect(r.admitted).toHaveLength(0); + }); + + test('a sport with no resolver is unchanged', () => { + expect(admit([prop({ event_binding_status: 'UNSUPPORTED' })]).admitted).toHaveLength(1); + expect(admit([prop({})]).admitted).toHaveLength(1); + }); + + test('rejected props are RETAINED for diagnostics, not discarded', () => { + const r = admit([prop({ event_binding_status: 'CONTRADICTED' })]); + expect(r.rejected).toHaveLength(1); + expect(r.reasons.EVENT_PLAYER_TEAM_CONTRADICTION).toBe(1); + }); + + test('the gate runs BEFORE dedupe and grading, exactly once', () => { + const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8'); + expect(src.indexOf('admitForGrading(props, sport)')).toBeLessThan(src.indexOf('dedupeProps(gate.admitted')); + // One admission seam, not scattered checks. + expect((src.match(/admitForGrading\(/g) || []).length).toBe(2); // definition + call + }); +}); + +describe('ROSTER TEMPORAL SEMANTICS — evidence must describe the right moment', () => { + const { nameKey } = require('../../src/utils/playerName'); + const SLATE = [ + { gamePk: 823825, gameNumber: 1, gameDate: '2026-08-26T22:40:00Z', home: { team: 'Miami Marlins', teamId: 146 }, away: { team: 'Boston Red Sox', teamId: 111 } }, + { gamePk: 824878, gameNumber: 1, gameDate: '2026-08-26T23:15:00Z', home: { team: 'Atlanta Braves', teamId: 144 }, away: { team: 'Los Angeles Dodgers', teamId: 119 } }, + ]; + const TEAMS = new Map([[nameKey('Joe Mack'), new Set(['MIA'])]]); + const misNested = { + player: 'Joe Mack', stat_type: 'total_bases', line: 0.5, book: 'pinnacle', + home_team: 'Atlanta Braves', away_team: 'Los Angeles Dodgers', + game_date: '2026-08-26', game_time: '2026-08-26T23:15:00Z', + }; + + test('date-valid evidence yields CONTRADICTED', () => { + const r = E.resolveEvent('mlb', misNested, SLATE, TEAMS, true); + expect(r.event_binding_status).toBe(E.BINDING_STATUS.CONTRADICTED); + }); + + test('evidence that is NOT date-valid yields UNRESOLVED, never an accusation', () => { + const r = E.resolveEvent('mlb', misNested, SLATE, TEAMS, false); + expect(r.event_binding_status).toBe(E.BINDING_STATUS.UNRESOLVED); + expect(r.reason).toBe('participant_evidence_not_date_valid'); + // Either way it does not grade — but the two verdicts stay distinct. + expect(r.canonical_event_id).toBeNull(); + }); + + test('evidenceIsDateValid requires date-scoping AND a matching date', () => { + expect(E.evidenceIsDateValid({ date_scoped: true, as_of_date: '2026-08-26' }, '2026-08-26')).toBe(true); + expect(E.evidenceIsDateValid({ date_scoped: true, as_of_date: '2026-08-01' }, '2026-08-26')).toBe(false); + expect(E.evidenceIsDateValid({ date_scoped: false, as_of_date: null }, '2026-08-26')).toBe(false); + expect(E.evidenceIsDateValid(null, '2026-08-26')).toBe(false); + }); + + test('the roster fetch is scoped to the slate date', async () => { + const seen = []; + await E.buildPlayerTeamIndex(SLATE, { + asOfDate: '2026-08-26', + getTeamRoster: async (id, asOf) => { seen.push(asOf); return [{ id, name: 'Joe Mack' }]; }, + }); + expect(new Set(seen)).toEqual(new Set(['2026-08-26'])); + }); + + test('an unscoped index reports date_scoped false', async () => { + const b = await E.buildPlayerTeamIndex(SLATE, { + getTeamRoster: async (id) => [{ id, name: 'Joe Mack' }], + }); + expect(b.stats.date_scoped).toBe(false); + expect(E.evidenceIsDateValid(b.stats, '2026-08-26')).toBe(false); + }); + + test('the adapter passes the date through to statsapi', () => { + const src = fs.readFileSync(path.join(ROOT, 'src/services/adapters/mlbStatsAdapter.js'), 'utf8'); + expect(src).toMatch(/asOfDate \? `&date=/); + expect(src).toMatch(/mlbstats:roster:\$\{teamId\}:\$\{season\}:\$\{asOfDate \|\| 'now'\}/); + }); +}); + +describe('MIS-NESTED MARKETS ARE REFUSED, NEVER RE-BOUND', () => { + const { nameKey } = require('../../src/utils/playerName'); + const SLATE = [ + { gamePk: 823825, gameNumber: 1, gameDate: '2026-08-26T22:40:00Z', home: { team: 'Miami Marlins', teamId: 146 }, away: { team: 'Boston Red Sox', teamId: 111 } }, + { gamePk: 824878, gameNumber: 1, gameDate: '2026-08-26T23:15:00Z', home: { team: 'Atlanta Braves', teamId: 144 }, away: { team: 'Los Angeles Dodgers', teamId: 119 } }, + { gamePk: 824234, gameNumber: 1, gameDate: '2026-08-26T17:10:00Z', home: { team: 'Detroit Tigers', teamId: 116 }, away: { team: 'Tampa Bay Rays', teamId: 139 } }, + { gamePk: 824555, gameNumber: 1, gameDate: '2026-08-26T23:40:00Z', home: { team: 'Chicago White Sox', teamId: 145 }, away: { team: 'Texas Rangers', teamId: 140 } }, + ]; + const TEAMS = new Map([ + [nameKey('Joe Mack'), new Set(['MIA'])], + [nameKey('Yandy Diaz'), new Set(['TB'])], + ]); + + test('JOE MACK: contradicted, not re-bound to the Marlins game', () => { + const r = E.resolveEvent('mlb', { + player: 'Joe Mack', stat_type: 'total_bases', line: 0.5, book: 'pinnacle', + home_team: 'Atlanta Braves', away_team: 'Los Angeles Dodgers', + game_date: '2026-08-26', game_time: '2026-08-26T23:15:00Z', + }, SLATE, TEAMS, true); + expect(r.event_binding_status).toBe('CONTRADICTED'); + // The refusal must NOT quietly relocate the prop to gamePk 823825. The + // provider record is invalid; inventing a different event is inventing + // provenance. + expect(r.canonical_event_id).toBeNull(); + expect(r.canonical_event_id).not.toBe('mlb:gamepk:823825'); + }); + + test('YANDY DIAZ: contradicted, not re-bound to the Rays game', () => { + const r = E.resolveEvent('mlb', { + player: 'Yandy Diaz', stat_type: 'total_bases', line: 0.5, book: 'pinnacle', + home_team: 'Chicago White Sox', away_team: 'Texas Rangers', + game_date: '2026-08-26', game_time: '2026-08-26T23:40:00Z', + }, SLATE, TEAMS, true); + expect(r.event_binding_status).toBe('CONTRADICTED'); + expect(r.canonical_event_id).toBeNull(); + expect(r.canonical_event_id).not.toBe('mlb:gamepk:824234'); + }); + + test('each player CORRECTLY associated market remains eligible', () => { + const gs = require('../../src/services/gradeSlateService'); + const good = [ + { player: 'Joe Mack', stat_type: 'total_bases', line: 0.5, book: 'draftkings', home_team: 'Miami Marlins', away_team: 'Boston Red Sox', game_time: '2026-08-26T22:40:00Z' }, + { player: 'Yandy Diaz', stat_type: 'total_bases', line: 0.5, book: 'draftkings', home_team: 'Detroit Tigers', away_team: 'Tampa Bay Rays', game_time: '2026-08-26T17:10:00Z' }, + ]; + E.attachEventIdentity('mlb', good, SLATE, TEAMS, true); + for (const p of good) expect(p.event_binding_status).toBe('RESOLVED'); + expect(gs.__internals.admitForGrading(good, 'mlb').admitted).toHaveLength(2); + }); + + test('the mis-nested props do not survive admission', () => { + const gs = require('../../src/services/gradeSlateService'); + const bad = [ + { player: 'Joe Mack', stat_type: 'total_bases', line: 0.5, book: 'pinnacle', home_team: 'Atlanta Braves', away_team: 'Los Angeles Dodgers', game_time: '2026-08-26T23:15:00Z' }, + { player: 'Yandy Diaz', stat_type: 'total_bases', line: 0.5, book: 'pinnacle', home_team: 'Chicago White Sox', away_team: 'Texas Rangers', game_time: '2026-08-26T23:40:00Z' }, + ]; + E.attachEventIdentity('mlb', bad, SLATE, TEAMS, true); + const gate = gs.__internals.admitForGrading(bad, 'mlb'); + expect(gate.admitted).toHaveLength(0); + expect(gate.rejected).toHaveLength(2); + expect(gate.reasons.EVENT_PLAYER_TEAM_CONTRADICTION).toBe(2); + }); + + test('DOUBLEHEADER survives the stricter gate — both games still admitted', () => { + const gs = require('../../src/services/gradeSlateService'); + const DH = [ + { gamePk: 824514, gameNumber: 1, gameDate: '2026-08-17T17:40:00Z', home: { team: 'Cincinnati Reds', teamId: 113 }, away: { team: 'St. Louis Cardinals', teamId: 138 } }, + { gamePk: 824478, gameNumber: 2, gameDate: '2026-08-17T22:40:00Z', home: { team: 'Cincinnati Reds', teamId: 113 }, away: { team: 'St. Louis Cardinals', teamId: 138 } }, + ]; + const teams = new Map([[nameKey('Andre Pallante'), new Set(['STL'])]]); + const props = ['2026-08-17T17:40:00Z', '2026-08-17T22:40:00Z'].map((t) => ({ + player: 'Andre Pallante', stat_type: 'strikeouts', line: 4.5, book: 'draftkings', + home_team: 'Cincinnati Reds', away_team: 'St. Louis Cardinals', game_time: t, + })); + E.attachEventIdentity('mlb', props, DH, teams, true); + expect(props.map((p) => p.event_binding_status)).toEqual(['RESOLVED', 'RESOLVED']); + expect(new Set(props.map((p) => p.canonical_event_id)).size).toBe(2); + expect(gs.__internals.admitForGrading(props, 'mlb').admitted).toHaveLength(2); + }); +});