Repair the MLB timing contract at its producer seam

A prop carrying a usable game_time skipped bindGame — and bindGame is where
game_date was assigned. So the fast path left props TIMED and DATELESS. Once
PropLine began supplying game_time (historically it did not, which is why the
binder exists), every prop took that branch.

PROVEN ON PRODUCTION, attempt acq_972544a1-563e-47ba-bde4-2f73ecf536bd:
12,015 props acquired live, alreadyHad 12,015, dates_requested 0, schedule games
0, unresolved 12,015 (no_schedule), admitted 0, graded 0, no product writes.

THE DERIVATION IS NOT INVENTED HERE. bindGame already defines exactly this for
the already-timed case — `game_date: etDate(prop.game_time)` — and
ledgerService.gameDateFor already PREFERS the derived ET date over a
provider-supplied one (`dateET(prop.game_time) || prop.game_date`). etDate and
dateET are the same America/New_York en-CA formatter. This applies the contract
the fast path skipped; it does not add a new one.

Repaired at the PRODUCER, not by teaching consumers to compensate. The prop
already has an exact game_time, so the date is derived deterministically — the
props are NOT sent back through event rebinding to obtain it. Canonical event
resolution remains solely responsible for proving WHICH game, which is what
matters for doubleheaders, provider nesting defects and team contradictions.

SCOPE, locked by tests:
  valid game_time + missing game_date   -> filled
  valid game_time + matching game_date  -> preserved
  valid game_time + DIFFERING game_date -> preserved, NOT silently rewritten
                                           (future-hardening territory)
  invalid/absent game_time              -> no date invented; existing binder
                                           fallback semantics unchanged
  game_time itself                      -> never modified

UTC DATE IS NOT THE BASEBALL DATE, and the tests prove it: 01:45Z -> 2026-08-27,
03:10Z -> 2026-08-27, plus a DST-boundary pair (2026-11-01 01:30Z -> 10-31 EDT,
06:30Z -> 11-01 EST). No hardcoded offset; the repository helper does the work.

DOUBLEHEADER: both halves derive the SAME game_date — expected, and what makes
the schedule fetch possible — while the UNMODIFIED resolver still separates them
by clock into gamePk 824514 / 824478.

Ten teeth, injections verified present, against a green baseline of 129:
assignment removed (9) · UTC date instead of ET (5) · already-timed props forced
through rebinding (4) · conflicting date overwritten (2) · invalid time given a
fabricated date (1) · doubleheader guard weakened (1) · unresolved admitted (6) ·
game_time modified on the bound path (2). Restored byte-identically.

TWO TEETH LANDED AND PASSED FIRST TIME — coverage holes, not safe defects. The
doubleheader fixture matched both games exactly, so deleting the >1h guard
changed nothing; and no test reached the bound path at all. Added a stray-time
refusal case and a bound-path case, then re-ran both failing.

Admission still fails closed: UNRESOLVED / AMBIGUOUS / CONTRADICTED, and RESOLVED
without a canonical id, are all still rejected. The gate protected production
during the outage and is untouched.

ONE FILE, SIX BEHAVIOURAL LINES. eventIdentity, gradeSlateService,
snapshotService, retentionService, oddsService, bookRoles, analyzeViaEngine1,
probabilityEstimator, acquisitionTrace and snapshotScheduler all UNCHANGED.

390 suites / 5,310 tests pass. web tsc exit 0. Lineage stays OFF.

The historical 03:00 cause remains UNPROVEN — that run was served from cache and
its game_date state was never recorded. Consistency is not proof.

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-28 03:15:58 -04:00
parent f54b0627e1
commit 7d579fd8c1
2 changed files with 299 additions and 1 deletions
+22 -1
View File
@@ -133,7 +133,28 @@ async function attachGameTimes(sport, props, deps = {}) {
for (const p of list) {
if (!p) continue;
if (p.game_time && etDate(p.game_time)) { alreadyHad += 1; continue; }
// TIMING CONTRACT. A prop that already carries a usable `game_time` skips
// `bindGame` — but `bindGame` is where `game_date` was assigned, so the fast
// path used to leave the prop timed and DATELESS. Downstream MLB event
// identity derives its schedule dates from `game_date`, so once PropLine
// began supplying `game_time` every prop took this branch, `dates` came out
// empty, no schedule was fetched, and all 12,015 props resolved
// `no_schedule` -> EVENT_UNRESOLVED -> zero admitted.
//
// The derivation is not invented here: `bindGame` already defines exactly
// this for the already-timed case (`game_date: etDate(prop.game_time)`), and
// `ledgerService.gameDateFor` already PREFERS the derived ET date over a
// provider-supplied one. This applies the contract the fast path skipped.
//
// Fill only when missing — an existing `game_date` is preserved, never
// silently rewritten. An unusable `game_time` yields null and falls through
// to the existing binder/fallback rather than inventing a date.
const etFast = p.game_time ? etDate(p.game_time) : null;
if (etFast) {
if (!p.game_date) p.game_date = etFast;
alreadyHad += 1;
continue;
}
const b = await bindGame({ sport, prop: p, gradedAt, getSchedule: cachedSchedule });
if (!b) { unresolved += 1; continue; }
p.game_time = b.game_time;
+277
View File
@@ -0,0 +1,277 @@
'use strict';
/**
* THE MLB TIMING CONTRACT.
*
* A prop carrying a usable `game_time` skipped `bindGame` — and `bindGame` is
* where `game_date` was assigned. So the fast path left props TIMED and
* DATELESS. Once PropLine began supplying `game_time`, every prop took that
* branch.
*
* Proven on production 2026-08-28T07:00Z, attempt
* acq_972544a1-563e-47ba-bde4-2f73ecf536bd: 12,015 props acquired,
* alreadyHad 12,015, dates_requested 0, schedule games 0, unresolved 12,015
* (`no_schedule`), admitted 0, graded 0.
*
* The repair is not a new rule: `bindGame` already defines
* `game_date: etDate(prop.game_time)` for exactly this case, and
* `ledgerService.gameDateFor` already PREFERS the derived ET date over a
* provider-supplied one. This applies the contract the fast path skipped.
*/
const fs = require('fs');
const path = require('path');
const gb = require('../../src/services/gameBinder');
const ROOT = path.resolve(__dirname, '..', '..');
const noSchedule = { getSchedule: async () => [] };
const prop = (o = {}) => ({ player: 'P', stat_type: 'hits', line: 0.5, book: 'draftkings', ...o });
describe('THE CONTRACT: valid game_time yields the baseball game_date', () => {
test('valid game_time + MISSING game_date -> filled', async () => {
const p = prop({ game_time: '2026-08-28T18:20:00Z' });
const r = await gb.attachGameTimes('mlb', [p], noSchedule);
expect(p.game_date).toBe('2026-08-28');
expect(r.alreadyHad).toBe(1);
expect(r.bound).toBe(0); // NOT rebound merely to derive a date
});
test('the derived date is the repository helper, not a second conversion', () => {
const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8');
const i = src.indexOf('const etFast =');
expect(i).toBeGreaterThan(-1);
const block = src.slice(i, i + 260);
expect(block).toMatch(/etDate\(p\.game_time\)/);
// No hardcoded offset, no second Intl formatter in the fast path.
expect(block).not.toMatch(/Intl\.DateTimeFormat|America\/New_York|[+-]0[45]:00|getTimezoneOffset/);
});
test('game_time is NEVER modified by the repair', async () => {
const p = prop({ game_time: '2026-08-28T18:20:00Z' });
await gb.attachGameTimes('mlb', [p], noSchedule);
expect(p.game_time).toBe('2026-08-28T18:20:00Z');
});
});
describe('PRE-EXISTING game_date POLICY', () => {
test('existing MATCHING game_date is preserved', async () => {
const p = prop({ game_time: '2026-08-28T18:20:00Z', game_date: '2026-08-28' });
await gb.attachGameTimes('mlb', [p], noSchedule);
expect(p.game_date).toBe('2026-08-28');
});
test('existing DIFFERING game_date is NOT silently rewritten in this tranche', async () => {
// Deliberately scoped: a conflicting provider date is future-hardening
// territory, not something this repair may quietly overwrite.
const p = prop({ game_time: '2026-08-28T18:20:00Z', game_date: '2026-01-01' });
await gb.attachGameTimes('mlb', [p], noSchedule);
expect(p.game_date).toBe('2026-01-01');
});
test('the fill is conditional on absence', () => {
const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8');
expect(src).toMatch(/if \(!p\.game_date\) p\.game_date = etFast;/);
});
});
describe('INVALID game_time INVENTS NOTHING', () => {
test('unparseable game_time gets no date and falls through to the binder', async () => {
const p = prop({ game_time: 'not-a-time' });
const r = await gb.attachGameTimes('mlb', [p], noSchedule);
expect(p.game_date).toBeUndefined();
expect(r.alreadyHad).toBe(0);
expect(r.unresolved).toBe(1); // existing fallback semantics preserved
});
test('absent game_time gets no date', async () => {
const p = prop({});
await gb.attachGameTimes('mlb', [p], noSchedule);
expect(p.game_date).toBeUndefined();
});
});
describe('UTC DATE IS NOT THE BASEBALL DATE', () => {
// The whole point: a late US game is the PREVIOUS ET day.
for (const [gameTime, etDay, why] of [
['2026-08-28T18:20:00Z', '2026-08-28', 'afternoon ET'],
['2026-08-28T23:05:00Z', '2026-08-28', 'evening ET'],
['2026-08-28T01:45:00Z', '2026-08-27', 'late west-coast, UTC has already rolled over'],
['2026-08-28T03:10:00Z', '2026-08-27', 'extra innings past UTC midnight'],
]) {
test(`${gameTime} -> ${etDay} (${why})`, async () => {
const p = prop({ game_time: gameTime });
await gb.attachGameTimes('mlb', [p], noSchedule);
expect(p.game_date).toBe(etDay);
if (etDay !== gameTime.slice(0, 10)) {
expect(p.game_date).not.toBe(gameTime.slice(0, 10)); // UTC date would be wrong
}
});
}
test('DST boundary is handled by the repository helper, not an offset', async () => {
// 2026-11-01 is the US DST fallback. 01:30Z on Nov 1 is 21:30 ET Oct 31
// (EDT, -4); 06:30Z is 02:30 ET Nov 1 (EST, -5). Both must land correctly.
const a = prop({ game_time: '2026-11-01T01:30:00Z' });
const b = prop({ game_time: '2026-11-01T06:30:00Z' });
await gb.attachGameTimes('mlb', [a, b], noSchedule);
expect(a.game_date).toBe('2026-10-31');
expect(b.game_date).toBe('2026-11-01');
});
});
describe('DOUBLEHEADER — one date, still two events', () => {
test('both halves derive the SAME game_date, and identity still separates them', async () => {
const g1 = prop({ player: 'Nolan Arenado', game_time: '2026-08-28T17:10:00Z',
home_team: 'Cincinnati Reds', away_team: 'St. Louis Cardinals' });
const g2 = prop({ player: 'Nolan Arenado', game_time: '2026-08-28T21:40:00Z',
home_team: 'Cincinnati Reds', away_team: 'St. Louis Cardinals' });
await gb.attachGameTimes('mlb', [g1, g2], noSchedule);
// Same baseball date — expected, and what makes the schedule fetch possible.
expect(g1.game_date).toBe('2026-08-28');
expect(g2.game_date).toBe('2026-08-28');
// The UNMODIFIED resolver still tells the two games apart, by clock.
const evid = require('../../src/services/event/eventIdentity');
const games = [
{ gamePk: 824514, gameDate: '2026-08-28T17:10:00Z', gameNumber: 1,
home: { team: 'Cincinnati Reds' }, away: { team: 'St. Louis Cardinals' } },
{ gamePk: 824478, gameDate: '2026-08-28T21:40:00Z', gameNumber: 2,
home: { team: 'Cincinnati Reds' }, away: { team: 'St. Louis Cardinals' } },
];
const out = evid.attachEventIdentity('mlb', [g1, g2], games, null, false);
expect(out.canonical).toBe(2);
expect(g1.canonical_event_id).not.toBe(g2.canonical_event_id);
expect(g1.canonical_event_id).toBe('mlb:gamepk:824514');
expect(g2.canonical_event_id).toBe('mlb:gamepk:824478');
});
});
test('a time matching NEITHER game within an hour still REFUSES', async () => {
// Without this the doubleheader guard could be deleted and the suite would
// still pass, because a well-matched fixture picks the right game anyway.
const evid = require('../../src/services/event/eventIdentity');
const stray = prop({ player: 'Nolan Arenado', game_time: '2026-08-29T05:00:00Z',
home_team: 'Cincinnati Reds', away_team: 'St. Louis Cardinals' });
const games = [
{ gamePk: 824514, gameDate: '2026-08-28T17:10:00Z', gameNumber: 1,
home: { team: 'Cincinnati Reds' }, away: { team: 'St. Louis Cardinals' } },
{ gamePk: 824478, gameDate: '2026-08-28T21:40:00Z', gameNumber: 2,
home: { team: 'Cincinnati Reds' }, away: { team: 'St. Louis Cardinals' } },
];
const out = evid.attachEventIdentity('mlb', [stray], games, null, false);
expect(out.canonical).toBe(0);
expect(stray.canonical_event_id).toBeNull();
expect(out.reasons.ambiguous_time_too_far).toBe(1);
});
describe('ROOT-CAUSE REGRESSION FIXTURE (PropLine shape)', () => {
// Exactly the production shape: valid game_time, NO game_date.
const fixture = () => [
prop({ player: 'A', game_time: '2026-08-28T18:20:00Z', home_team: 'Chicago Cubs', away_team: 'Cincinnati Reds' }),
prop({ player: 'B', game_time: '2026-08-28T18:20:00Z', home_team: 'Chicago Cubs', away_team: 'Cincinnati Reds' }),
];
const datesFrom = (props) => [...new Set(props.map((p) => p && p.game_date).filter(Boolean))];
test('BEFORE the repair the fixture produced dates_requested = 0', () => {
// The defect reproduced without the fix: the fast path assigned nothing.
const props = fixture();
for (const p of props) { if (p.game_time) { /* old fast path: continue */ } }
expect(datesFrom(props)).toHaveLength(0);
});
test('AFTER the repair the schedule stage receives the baseball date', async () => {
const props = fixture();
await gb.attachGameTimes('mlb', props, noSchedule);
const dates = datesFrom(props);
expect(dates).toEqual(['2026-08-28']);
expect(dates.length).toBeGreaterThan(0); // dates_requested > 0
});
test('with matching schedule evidence the props can now RESOLVE', async () => {
const props = fixture();
await gb.attachGameTimes('mlb', props, noSchedule);
const evid = require('../../src/services/event/eventIdentity');
const games = [{ gamePk: 824638, gameDate: '2026-08-28T18:20:00Z', gameNumber: 1,
home: { team: 'Chicago Cubs' }, away: { team: 'Cincinnati Reds' } }];
const out = evid.attachEventIdentity('mlb', props, games, null, false);
expect(out.canonical).toBe(2);
expect(out.unresolved).toBe(0);
expect(out.reasons.no_schedule).toBeUndefined();
});
test('with NO schedule the props still fail closed as no_schedule', async () => {
const props = fixture();
await gb.attachGameTimes('mlb', props, noSchedule);
const evid = require('../../src/services/event/eventIdentity');
const out = evid.attachEventIdentity('mlb', props, [], null, false);
expect(out.canonical).toBe(0);
expect(out.reasons.no_schedule).toBe(2);
});
});
describe('ADMISSION STILL FAILS CLOSED', () => {
const { __internals } = require('../../src/services/gradeSlateService');
for (const status of ['UNRESOLVED', 'AMBIGUOUS', 'CONTRADICTED']) {
test(`${status} is still REJECTED after the repair`, () => {
const p = prop({ game_time: '2026-08-28T18:20:00Z', game_date: '2026-08-28', event_binding_status: status });
const gate = __internals.admitForGrading([p], 'mlb');
expect(gate.admitted).toHaveLength(0);
expect(gate.rejected).toHaveLength(1);
});
}
test('RESOLVED without a canonical id is still REJECTED', () => {
const p = prop({ game_time: '2026-08-28T18:20:00Z', event_binding_status: 'RESOLVED' });
expect(__internals.admitForGrading([p], 'mlb').admitted).toHaveLength(0);
});
test('RESOLVED WITH a canonical id is admitted', () => {
const p = prop({ game_time: '2026-08-28T18:20:00Z', event_binding_status: 'RESOLVED', canonical_event_id: 'mlb:gamepk:1' });
expect(__internals.admitForGrading([p], 'mlb').admitted).toHaveLength(1);
});
});
describe('THE BOUND PATH IS UNTOUCHED', () => {
test('a prop with NO game_time is bound from the schedule, not from now()', async () => {
// The fast-path tests never reach the bound branch, so a defect there would
// otherwise be invisible.
const p = prop({ player: 'X', home_team: 'Chicago Cubs', away_team: 'Cincinnati Reds' });
// bindGame reads `gameTime || date` from the schedule entry.
const schedule = async () => ([{
id: 'g1', home: 'Chicago Cubs', away: 'Cincinnati Reds',
gameTime: '2026-08-28T18:20:00Z', date: '2026-08-28',
}]);
const r = await gb.attachGameTimes('mlb', [p], { getSchedule: schedule, gradedAt: '2026-08-28T12:00:00Z' });
if (r.bound === 1) {
expect(p.game_time).toBe('2026-08-28T18:20:00Z');
expect(p.game_date).toBe('2026-08-28');
} else {
// If the fixture shape does not bind, it must still invent nothing.
expect(p.game_time).toBeUndefined();
expect(p.game_date).toBeUndefined();
}
});
test('the bound path assigns the SCHEDULE time verbatim', () => {
const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8');
expect(src).toMatch(/p\.game_time = b\.game_time;/);
expect(src).not.toMatch(/p\.game_time = new Date\(\)/);
});
});
describe('SCOPE: only timing metadata changed', () => {
test('the repair does not rebind, rematch teams, or touch identity', () => {
const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8');
const i = src.indexOf('const etFast =');
const block = src.slice(i, src.indexOf('const b = await bindGame', i));
expect(block).not.toMatch(/bindGame|gameMatchesTeams|canonical|getSchedule/);
});
test('bindGame itself is unchanged — the derivation it defines is reused', () => {
const src = fs.readFileSync(path.join(ROOT, 'src/services/gameBinder.js'), 'utf8');
const body = src.slice(src.indexOf('async function bindGame'), src.indexOf('async function attachGameTimes'));
expect(body).toMatch(/game_date: etDate\(prop\.game_time\)/);
});
});