cc5bdf5797
`LINEAGE_CANARY_SPORTS=mlb` was parsed once at module load and frozen, so an enabled canary stayed writable for the whole process lifetime. On 2026-08-29 it was left set and the 22:00Z, 01:00Z and 03:00Z scheduled snapshots each wrote lineage overnight with nobody watching. That history happened to be correct. Lineage is append-only evidence, so a defective canary would have written irreversible WRONG evidence exactly as quietly. Correct history was luck, not a safety property. NEW CONTRACT: LINEAGE_CANARY_SPORTS=mlb@2026-08-30T18:00:00Z Absolute UTC instant only -- no duration, no local timezone. A relative "4h" would silently restart on every redeploy, which is the defect being removed. LEGACY `mlb` NO LONGER ACTIVATES ANYTHING. It is INVALID_MISSING_EXPIRY. Leaving it working would have left the defect in place behind a nicer-looking alternative, so this is the load-bearing half of the repair. EXPIRY IS EVALUATED AT THE WRITE GATE, not at startup. `isEnabled(sport, now)` re-reads the clock on every call and `lineageCanaryEnabled` threads it through, so a lease turns itself off with no operator, no restart, no Redis and no network. A design where an expired canary keeps writing until someone restarts is the same failure in a different hat. MAX_LEASE is FOUR HOURS, and the bound is proven rather than chosen. MLB ticks are [14,19,22,1,3] UTC; exhaustively over a minute grid across UTC date boundaries, the shortest span enclosing THREE consecutive ticks is 22:00 -> 01:00 -> 03:00 = five hours. Four hours encloses at most two, with an hour of margin. (An earlier note claimed six hours admitted two. It admits three; that claim was false and the test now pins the arithmetic.) The bound is a function of the scheduler, so a test reads the hours from sportCadence and fails if a cadence change invalidates the proof. Everything fails closed: absent, empty, bare sport, comma list, two leases, duplicate @, non-leasable sport, missing timezone, numeric offset, malformed, over-long, already-expired, expiry-equals-now, and an unreadable clock. Configured-but-EXPIRED stays distinguishable from never-configured so automatic containment is auditable. No Redis, no Supabase, no counter, no network in the lease path -- an unreachable dependency must never decide whether lineage may write. Lineage algorithms, cache-date, participant and retention identity untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
944 lines
46 KiB
JavaScript
944 lines
46 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* CANONICAL EVENT IDENTITY + PUBLICATION COMMIT TRUTH.
|
|
*
|
|
* Two findings drive this suite, both verified against production:
|
|
*
|
|
* 1. EVENT LABELS ARE NOT EVENT IDENTITY. `gameIdFor` derives
|
|
* `sport:date:away@home`, which has no occurrence component, so both halves
|
|
* of a doubleheader produce a byte-identical string. MLB 2026 through
|
|
* 2026-08-27: 19 doubleheaders / 38 real games collapse into 19 labels. The
|
|
* 2026-08-17 St. Louis @ Cincinnati pair (gamePk 824514 / 824478) landed in
|
|
* ONE id holding 171 ledger rows and FOUR distinct starting pitchers.
|
|
*
|
|
* 2. PUBLISHED MUST MEAN COMMITTED. Retention persisted at
|
|
* snapshotService:1137 while the authoritative slate write is at :1152, and
|
|
* the winner was marked published earlier still.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const E = require('../../src/services/event/eventIdentity');
|
|
const L = require('../../src/services/read/readLineage');
|
|
const retention = require('../../src/services/retentionService');
|
|
|
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
|
|
// The REAL doubleheader, from statsapi.
|
|
const DH_GAMES = [
|
|
{ gamePk: 824514, gameNumber: 1, gameDate: '2026-08-17T17:40:00Z', home: { team: 'Cincinnati Reds' }, away: { team: 'St. Louis Cardinals' } },
|
|
{ gamePk: 824478, gameNumber: 2, gameDate: '2026-08-17T22:40:00Z', home: { team: 'Cincinnati Reds' }, away: { team: 'St. Louis Cardinals' } },
|
|
];
|
|
const dhProp = (time, over = {}) => ({
|
|
player: 'Andre Pallante', stat_type: 'strikeouts', line: 4.5, book: 'draftkings',
|
|
home_team: 'Cincinnati Reds', away_team: 'St. Louis Cardinals',
|
|
game_date: '2026-08-17', game_time: time, ...over,
|
|
});
|
|
|
|
describe('EVENT IDENTITY — canonical, sport-neutral contract', () => {
|
|
test('MLB resolves to a namespaced canonical id from statsapi gamePk', () => {
|
|
const r = E.resolveEvent('mlb', dhProp('2026-08-17T17:40:00Z'), DH_GAMES);
|
|
expect(r.canonical_event_id).toBe('mlb:gamepk:824514');
|
|
expect(r.event_identity_source).toBe(E.IDENTITY_SOURCE.MLB_STATSAPI_GAMEPK);
|
|
expect(r.event_identity_method).toBe(E.IDENTITY_METHOD.CANONICAL);
|
|
expect(r.event_occurrence).toBe(1);
|
|
});
|
|
|
|
test('an unsupported sport records UNSUPPORTED_SPORT, never a guessed id', () => {
|
|
for (const sp of ['wnba', 'nba', 'soccer', 'nfl']) {
|
|
const r = E.resolveEvent(sp, dhProp('2026-08-17T17:40:00Z'), DH_GAMES);
|
|
expect(r.event_identity_method).toBe(E.IDENTITY_METHOD.UNSUPPORTED_SPORT);
|
|
expect(r.canonical_event_id).toBeNull();
|
|
}
|
|
});
|
|
|
|
test('team resolution is canonical, handling every real spelling', () => {
|
|
const cases = [
|
|
['Cincinnati Reds', 'CIN'], ['CINReds', 'CIN'], ['CIN', 'CIN'],
|
|
['St. Louis Cardinals', 'STL'], ['St.LouisCardinals', 'STL'],
|
|
['New York Mets', 'NYM'], ['NYMets', 'NYM'], ['CLEGuardians', 'CLE'],
|
|
// The trap: both end in "sox". Longest nickname must win.
|
|
['Chicago White Sox', 'CWS'], ['ChicagoWhiteSox', 'CWS'],
|
|
['Boston Red Sox', 'BOS'], ['BOSRedSox', 'BOS'],
|
|
['Toronto Blue Jays', 'TOR'], ['TORBlueJays', 'TOR'],
|
|
];
|
|
for (const [name, abbr] of cases) expect(E.teamAbbr(name)).toBe(abbr);
|
|
expect(E.teamAbbr('Not A Team')).toBeNull();
|
|
expect(E.teamAbbr('')).toBeNull();
|
|
});
|
|
|
|
test('an unresolvable team name matches NOTHING rather than falling back to text', () => {
|
|
expect(E.teamsMatch(DH_GAMES[0], 'Cincinnati Reds', 'Some Fake Club')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('DOUBLEHEADER SAFETY — the mandatory proof', () => {
|
|
test('two games of one doubleheader receive DIFFERENT canonical ids', () => {
|
|
const g1 = E.resolveEvent('mlb', dhProp('2026-08-17T17:40:00Z'), DH_GAMES);
|
|
const g2 = E.resolveEvent('mlb', dhProp('2026-08-17T22:40:00Z'), DH_GAMES);
|
|
expect(g1.canonical_event_id).toBe('mlb:gamepk:824514');
|
|
expect(g2.canonical_event_id).toBe('mlb:gamepk:824478');
|
|
expect(g1.canonical_event_id).not.toBe(g2.canonical_event_id);
|
|
expect(g1.event_occurrence).toBe(1);
|
|
expect(g2.event_occurrence).toBe(2);
|
|
});
|
|
|
|
test('both survive dedupe — same player, stat and line, different games', () => {
|
|
const gs = require('../../src/services/gradeSlateService');
|
|
const props = [dhProp('2026-08-17T17:40:00Z'), dhProp('2026-08-17T22:40:00Z')];
|
|
E.attachEventIdentity('mlb', props, DH_GAMES);
|
|
const kept = gs.__internals
|
|
? gs.__internals.dedupeProps(props, 100)
|
|
: null;
|
|
expect(kept).not.toBeNull();
|
|
expect(kept).toHaveLength(2);
|
|
expect(new Set(kept.map((p) => p.canonical_event_id)).size).toBe(2);
|
|
});
|
|
|
|
test('their LEGACY derived label collides, and that no longer matters', () => {
|
|
const gameIdFor = require('../../src/services/ledgerService').__internals.gameIdFor;
|
|
const prop = { away_team: 'St. Louis Cardinals', home_team: 'Cincinnati Reds' };
|
|
const a = gameIdFor('mlb', prop, '2026-08-17');
|
|
const b = gameIdFor('mlb', prop, '2026-08-17');
|
|
expect(a).toBe(b); // the label cannot separate them...
|
|
// ...but canonical identity does, and lineage keys on that.
|
|
const base = {
|
|
sport: 'mlb', game_date: '2026-08-17', player_key: 'andre pallante',
|
|
stat: 'strikeouts', side: 'over', line: 4.5, game_id: a,
|
|
captured_at: '2026-08-17T14:00:00Z', published: true, grade: 'B', p_win: 0.6,
|
|
};
|
|
const r1 = { ...base, canonical_event_id: 'mlb:gamepk:824514' };
|
|
const r2 = { ...base, canonical_event_id: 'mlb:gamepk:824478' };
|
|
expect(L.readNaturalKey(r1)).not.toBe(L.readNaturalKey(r2));
|
|
});
|
|
|
|
test('neither game can supersede or overwrite the other', () => {
|
|
const base = {
|
|
sport: 'mlb', game_date: '2026-08-17', player_key: 'andre pallante',
|
|
stat: 'strikeouts', side: 'over', line: 4.5, game_id: 'mlb:2026-08-17:StL@Cin',
|
|
captured_at: '2026-08-17T14:00:00Z', published: true, grade: 'B', p_win: 0.6,
|
|
};
|
|
const g1 = { ...base, canonical_event_id: 'mlb:gamepk:824514' };
|
|
const g2 = { ...base, canonical_event_id: 'mlb:gamepk:824478' };
|
|
const r1 = L.resolveLineage({ candidate: g1, existing: [], mintReadId: () => 'RA' });
|
|
const row1 = {
|
|
id: 101, read_id: 'RA', read_natural_key: r1.read_natural_key, game_id: base.game_id,
|
|
claim_digest: r1.claim_digest, revision_ordinal: 0, lineage_action: 'ORIGIN',
|
|
supersedes_id: null, captured_at: base.captured_at, claim: g1,
|
|
};
|
|
const r2 = L.resolveLineage({ candidate: g2, existing: [row1], mintReadId: () => 'RB' });
|
|
expect(r2.read_id).toBe('RB');
|
|
expect(r2.action).toBe(L.LINEAGE_ACTION.ORIGIN);
|
|
expect(r2.supersedes_id).toBeNull();
|
|
});
|
|
|
|
test('identity is decided by TIME, and refuses when time cannot decide', () => {
|
|
const noTime = E.resolveEvent('mlb', dhProp(null), DH_GAMES);
|
|
expect(noTime.event_identity_method).toBe(E.IDENTITY_METHOD.UNRESOLVED);
|
|
expect(noTime.reason).toBe('ambiguous_no_game_time');
|
|
// A time matching neither game is refused rather than snapped to the nearer.
|
|
const farOff = E.resolveEvent('mlb', dhProp('2026-08-17T05:00:00Z'), DH_GAMES);
|
|
expect(farOff.event_identity_method).toBe(E.IDENTITY_METHOD.UNRESOLVED);
|
|
expect(farOff.reason).toBe('ambiguous_time_too_far');
|
|
});
|
|
|
|
test('a single-game day needs no time to resolve', () => {
|
|
const r = E.resolveEvent('mlb', dhProp(null), [DH_GAMES[0]]);
|
|
expect(r.canonical_event_id).toBe('mlb:gamepk:824514');
|
|
});
|
|
});
|
|
|
|
describe('DEDUPE SEMANTICS — collapse books, never events', () => {
|
|
const gs = require('../../src/services/gradeSlateService');
|
|
const dedupe = (props, n) => gs.__internals.dedupeProps(props, n);
|
|
|
|
test('the same proposition from several books still collapses to one', () => {
|
|
const props = ['draftkings', 'fanduel', 'betmgm'].map((book) =>
|
|
({ ...dhProp('2026-08-17T17:40:00Z'), book, canonical_event_id: 'mlb:gamepk:824514' }));
|
|
expect(dedupe(props, 100)).toHaveLength(1);
|
|
});
|
|
|
|
test('the same proposition in two events does NOT collapse', () => {
|
|
const props = [
|
|
{ ...dhProp('2026-08-17T17:40:00Z'), canonical_event_id: 'mlb:gamepk:824514' },
|
|
{ ...dhProp('2026-08-17T22:40:00Z'), canonical_event_id: 'mlb:gamepk:824478' },
|
|
];
|
|
expect(dedupe(props, 100)).toHaveLength(2);
|
|
});
|
|
|
|
test('without canonical identity behaviour is UNCHANGED from before', () => {
|
|
// Same teams, same date, no canonical id: the legacy fallback collapses
|
|
// them exactly as the old key did. The fix does not pretend to distinguish
|
|
// what it cannot.
|
|
const props = [dhProp('2026-08-17T17:40:00Z'), dhProp('2026-08-17T22:40:00Z')];
|
|
expect(dedupe(props, 100)).toHaveLength(1);
|
|
});
|
|
|
|
test('different games on the same date never collapse even without canonical id', () => {
|
|
const a = { ...dhProp('t'), home_team: 'Cincinnati Reds', away_team: 'St. Louis Cardinals' };
|
|
const b = { ...dhProp('t'), home_team: 'Boston Red Sox', away_team: 'Arizona Diamondbacks' };
|
|
expect(dedupe([a, b], 100)).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('PUBLICATION COMMIT TRUTH', () => {
|
|
const served = () => ({
|
|
sport: 'mlb', game_date: '2026-08-17', player_key: 'x', stat: 'hits', side: 'over',
|
|
line: 0.5, game_id: 'g', canonical_event_id: 'mlb:gamepk:824514',
|
|
captured_at: '2026-08-17T14:00:00Z', grade: 'B', p_win: 0.61, published: true,
|
|
});
|
|
const okClient = () => ({ from: () => ({ upsert: async () => ({ error: null }) }) });
|
|
|
|
test('capture does NOT resolve lineage — persist leaves it null', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8');
|
|
const persistBody = src.slice(src.indexOf('async function persist('));
|
|
expect(persistBody).not.toMatch(/attachLineage\(/);
|
|
});
|
|
|
|
test('the slate write PRECEDES the publication commit', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8');
|
|
const persistAt = src.indexOf('await persistRetention(enriched');
|
|
const slateAt = src.indexOf('cacheSet(`snapshot:${sp}:latest`');
|
|
const commitAt = src.indexOf('commitPublication({');
|
|
expect(persistAt).toBeGreaterThan(-1);
|
|
expect(slateAt).toBeGreaterThan(persistAt);
|
|
expect(commitAt).toBeGreaterThan(slateAt);
|
|
});
|
|
|
|
test('WINNER SELECTED + REDIS OK -> the exact snapshot becomes published', async () => {
|
|
const rows = [served()];
|
|
const out = await retention.commitPublication(
|
|
{ rows, publicationId: 'slate-1', publishedAt: '2026-08-17T15:00:00Z' },
|
|
{ getClient: okClient, fetchExisting: async () => [], mintReadId: () => 'R1' },
|
|
);
|
|
expect(out.published).toBe(1);
|
|
expect(rows[0].published_at).toBe('2026-08-17T15:00:00Z');
|
|
expect(rows[0].publication_id).toBe('slate-1');
|
|
expect(rows[0].lineage_action).toBe('ORIGIN');
|
|
});
|
|
|
|
test('WINNER SELECTED + REDIS FAILS -> nothing is published', () => {
|
|
// The commit is unreachable when the slate write throws: it sits after the
|
|
// awaited cacheSet in the same function, with no catch between them.
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8');
|
|
const slateAt = src.indexOf('cacheSet(`snapshot:${sp}:latest`');
|
|
const commitAt = src.indexOf('commitPublication({');
|
|
const between = src.slice(slateAt, commitAt);
|
|
expect(between).not.toMatch(/catch\s*\(/);
|
|
});
|
|
|
|
test('a row that was never served is not committed even in a served batch', async () => {
|
|
const rows = [served(), { ...served(), side: 'under', published: false }];
|
|
const out = await retention.commitPublication(
|
|
{ rows, publicationId: 'slate-1', publishedAt: 't' },
|
|
{ getClient: okClient, fetchExisting: async () => [], mintReadId: () => 'R1' },
|
|
);
|
|
expect(out.candidates).toBe(1);
|
|
expect(rows[1].published_at).toBeUndefined();
|
|
expect(rows[1].lineage_action).toBeUndefined();
|
|
});
|
|
|
|
test('REDIS OK + PUBLICATION UPDATE FAILS -> understated, never overstated', async () => {
|
|
const rows = [served()];
|
|
const failing = () => ({ from: () => ({ upsert: async () => ({ error: { message: 'db down' } }) }) });
|
|
const out = await retention.commitPublication(
|
|
{ rows, publicationId: 'slate-1', publishedAt: 't' },
|
|
{ getClient: failing, fetchExisting: async () => [], mintReadId: () => 'R1' },
|
|
);
|
|
expect(out.published).toBe(0);
|
|
expect(out.error).toBe('db down');
|
|
});
|
|
|
|
test('no database configured skips without throwing', async () => {
|
|
const out = await retention.commitPublication(
|
|
{ rows: [served()], publicationId: 's', publishedAt: 't' },
|
|
{ getClient: () => null },
|
|
);
|
|
expect(out.skipped).toBe(true);
|
|
expect(out.error).toBeNull();
|
|
});
|
|
|
|
test('retrying the commit does not manufacture a second publication', async () => {
|
|
const rows = [served()];
|
|
const deps = { getClient: okClient, fetchExisting: async () => [], mintReadId: () => 'R1' };
|
|
await retention.commitPublication({ rows, publicationId: 's', publishedAt: 't' }, deps);
|
|
const first = rows[0].claim_digest;
|
|
// Second run sees the first as existing history.
|
|
// A COMPLETED lineage action, in the shape production actually persists.
|
|
// `attachLineage` assigns the action, the state and both version stamps in
|
|
// one block, and all 1,024 live rows carry all nine fields — a digest with
|
|
// no schema version is uninterpretable, so the validity predicate requires
|
|
// them. This fixture previously omitted four of them, describing a row the
|
|
// writer cannot emit.
|
|
const existing = [{
|
|
id: 1, read_id: rows[0].read_id, read_natural_key: rows[0].read_natural_key,
|
|
game_id: rows[0].game_id, claim_digest: first, revision_ordinal: 0,
|
|
lineage_action: 'ORIGIN', supersedes_id: null, captured_at: rows[0].captured_at,
|
|
lineage_state: 'LIVE', lineage_version: 'lin@1',
|
|
claim_schema_version: 'claim@1', digest_algorithm_version: 'sha256-json-sorted@1',
|
|
claim: rows[0],
|
|
}];
|
|
const rows2 = [served()];
|
|
const out2 = await retention.commitPublication(
|
|
{ rows: rows2, publicationId: 's', publishedAt: 't' },
|
|
{ ...deps, fetchExisting: async () => existing },
|
|
);
|
|
expect(out2.lineage.recaptures).toBe(1);
|
|
expect(out2.lineage.revisions).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('PUBLICATION CLOCK', () => {
|
|
test('captured_at and published_at are different fields answering different questions', async () => {
|
|
const rows = [{
|
|
sport: 'mlb', game_date: '2026-08-17', player_key: 'x', stat: 'hits', side: 'over',
|
|
line: 0.5, game_id: 'g', canonical_event_id: 'mlb:gamepk:824514',
|
|
captured_at: '2026-08-17T14:00:00Z', grade: 'B', p_win: 0.61, published: true,
|
|
}];
|
|
await retention.commitPublication(
|
|
{ rows, publicationId: 's', publishedAt: '2026-08-17T15:07:00Z' },
|
|
{ getClient: () => ({ from: () => ({ upsert: async () => ({ error: null }) }) }), fetchExisting: async () => [], mintReadId: () => 'R1' },
|
|
);
|
|
expect(rows[0].captured_at).toBe('2026-08-17T14:00:00Z');
|
|
expect(rows[0].published_at).toBe('2026-08-17T15:07:00Z');
|
|
expect(rows[0].published_at).not.toBe(rows[0].captured_at);
|
|
});
|
|
});
|
|
|
|
describe('CONCURRENCY RECOVERY — bounded', () => {
|
|
const served = () => ({
|
|
sport: 'mlb', game_date: '2026-08-17', player_key: 'x', stat: 'hits', side: 'over',
|
|
line: 0.5, game_id: 'g', canonical_event_id: 'mlb:gamepk:824514',
|
|
captured_at: 't', grade: 'B', p_win: 0.61, published: true,
|
|
});
|
|
const conflict = { message: 'duplicate key value violates unique constraint "model_snapshots_supersedes_unique"' };
|
|
|
|
test('the supersedes conflict is recognised and other errors are not', () => {
|
|
expect(retention.isSupersedesConflict(conflict)).toBe(true);
|
|
expect(retention.isSupersedesConflict({ message: 'connection refused' })).toBe(false);
|
|
expect(retention.isSupersedesConflict(null)).toBe(false);
|
|
});
|
|
|
|
test('a losing writer re-resolves against the new head and succeeds', async () => {
|
|
let n = 0;
|
|
const sb = { from: () => ({ upsert: async () => { n += 1; return n === 1 ? { error: conflict } : { error: null }; } }) };
|
|
const out = await retention.commitPublication(
|
|
{ rows: [served()], publicationId: 's', publishedAt: 't' },
|
|
{ getClient: () => sb, fetchExisting: async () => [], mintReadId: () => 'R1' },
|
|
);
|
|
expect(out.conflicts).toBe(1);
|
|
expect(out.recovered).toBe(1);
|
|
expect(out.published).toBe(1);
|
|
});
|
|
|
|
test('a permanent conflict fails EXPLICITLY and is bounded', async () => {
|
|
let n = 0;
|
|
const sb = { from: () => ({ upsert: async () => { n += 1; return { error: conflict }; } }) };
|
|
const out = await retention.commitPublication(
|
|
{ rows: [served()], publicationId: 's', publishedAt: 't' },
|
|
{ getClient: () => sb, fetchExisting: async () => [], mintReadId: () => 'R1' },
|
|
);
|
|
expect(out.recovery_failed).toBe(1);
|
|
expect(out.error).toBeTruthy();
|
|
// 1 initial + FORK_RETRY_LIMIT attempts. Bounded, never a spin.
|
|
expect(n).toBe(1 + retention.FORK_RETRY_LIMIT);
|
|
expect(retention.FORK_RETRY_LIMIT).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
test('a non-conflict error is NOT retried', async () => {
|
|
let n = 0;
|
|
const sb = { from: () => ({ upsert: async () => { n += 1; return { error: { message: 'db down' } }; } }) };
|
|
const out = await retention.commitPublication(
|
|
{ rows: [served()], publicationId: 's', publishedAt: 't' },
|
|
{ getClient: () => sb, fetchExisting: async () => [], mintReadId: () => 'R1' },
|
|
);
|
|
expect(n).toBe(1);
|
|
expect(out.error).toBe('db down');
|
|
expect(out.conflicts).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('CHANGE SEMANTICS — identity is not an opinion', () => {
|
|
test('correcting a derived event label is NOT a BELIEF change', () => {
|
|
const a = { line: 0.5, side: 'over', p_win: 0.61, grade: 'B', canonical_event_id: null };
|
|
const b = { ...a, canonical_event_id: 'mlb:gamepk:824514' };
|
|
expect(L.CLAIM_FIELD_CLASSES.canonical_event_id).toBeUndefined();
|
|
expect(L.classifyChange(a, b).change_type).toBe(L.CHANGE_TYPE.NO_MATERIAL_PUBLISHED_CHANGE);
|
|
expect(L.classifyChange(a, b).belief_changed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('SETTLEMENT SEPARATION still holds after propagation', () => {
|
|
test('canonical event, publication and lineage fields are claim-owned', () => {
|
|
for (const f of ['published', 'claim_digest', 'revision_ordinal', 'change_type']) {
|
|
expect(L.settlementUpdateIsSafe({ outcome: 'hit', [f]: 'x' }).violations).toContain(f);
|
|
}
|
|
});
|
|
|
|
test('the real production settle patch still passes', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotSettlementService.js'), 'utf8');
|
|
const m = src.match(/\.update\(\{([\s\S]*?)\}\)/);
|
|
const keys = [...m[1].matchAll(/^\s*([a-z_0-9]+)\s*:/gm)].map((x) => x[1]);
|
|
expect(L.settlementUpdateIsSafe(Object.fromEntries(keys.map((k) => [k, 1])))).toMatchObject({ safe: true });
|
|
});
|
|
});
|
|
|
|
describe('PRODUCTION PRESERVATION', () => {
|
|
test('no product path imports event identity or lineage', () => {
|
|
const walk = (dir, out = []) => {
|
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) walk(full, out);
|
|
else if (/\.(js|jsx|ts|tsx)$/.test(e.name)) out.push(full);
|
|
}
|
|
return out;
|
|
};
|
|
const allowed = new Set([
|
|
path.join(ROOT, 'src/services/read/readLineage.js'),
|
|
path.join(ROOT, 'src/services/retentionService.js'),
|
|
path.join(ROOT, 'src/services/event/eventIdentity.js'),
|
|
path.join(ROOT, 'src/services/snapshotService.js'),
|
|
]);
|
|
const files = [
|
|
...walk(path.join(ROOT, 'src/routes')),
|
|
...(fs.existsSync(path.join(ROOT, 'web/src')) ? walk(path.join(ROOT, 'web/src')) : []),
|
|
].filter((f) => !allowed.has(f));
|
|
const offenders = files.filter((f) => {
|
|
const src = fs.readFileSync(f, 'utf8')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
return /(require\(|from\s+)['"`][^'"`]*(readLineage|eventIdentity)['"`]/.test(src);
|
|
}).map((f) => path.relative(ROOT, f));
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
test('the served envelope still comes from the cache', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/routes/snapshot.js'), 'utf8');
|
|
expect(src).toMatch(/cacheGet/);
|
|
expect(src).not.toMatch(/model_snapshots/);
|
|
expect(src).not.toMatch(/canonical_event_id/);
|
|
});
|
|
});
|
|
|
|
describe('CANARY SCOPE — bounded and reversible', () => {
|
|
const load = (val) => {
|
|
const prev = process.env.LINEAGE_CANARY_SPORTS;
|
|
if (val === undefined) delete process.env.LINEAGE_CANARY_SPORTS;
|
|
else process.env.LINEAGE_CANARY_SPORTS = val;
|
|
jest.resetModules();
|
|
// eslint-disable-next-line global-require
|
|
const m = require('../../src/services/snapshotService');
|
|
if (prev === undefined) delete process.env.LINEAGE_CANARY_SPORTS;
|
|
else process.env.LINEAGE_CANARY_SPORTS = prev;
|
|
return m;
|
|
};
|
|
|
|
test('DARK BY DEFAULT — an unset flag enables lineage for nothing', () => {
|
|
// This release deploys the integrity repairs with the observer off.
|
|
const m = load(undefined);
|
|
expect(m.LINEAGE_CANARY_SPORTS).toEqual([]);
|
|
for (const sp of ['mlb', 'wnba', 'nba', 'soccer']) {
|
|
expect(m.lineageCanaryEnabled(sp)).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('MLB is enabled only by an explicit BOUNDED LEASE', () => {
|
|
// Plain `mlb` is no longer an activation path — it means "forever", which
|
|
// is precisely the containment defect. Only sport@absolute-UTC-expiry works.
|
|
const now = new Date('2026-08-30T12:00:00Z');
|
|
const legacy = load('mlb');
|
|
expect(legacy.lineageCanaryEnabled('mlb', now)).toBe(false);
|
|
|
|
const m = load('mlb@2026-08-30T13:00:00Z');
|
|
expect(m.lineageCanaryEnabled('mlb', now)).toBe(true);
|
|
for (const sp of ['wnba', 'nba', 'soccer']) {
|
|
expect(m.lineageCanaryEnabled(sp, now)).toBe(false);
|
|
}
|
|
// And it turns itself off when the clock crosses, with no restart.
|
|
expect(m.lineageCanaryEnabled('mlb', new Date('2026-08-30T13:00:01Z'))).toBe(false);
|
|
});
|
|
|
|
test('an empty value is a kill switch needing no migration revert', () => {
|
|
const m = load('');
|
|
expect(m.LINEAGE_CANARY_SPORTS).toEqual([]);
|
|
expect(m.lineageCanaryEnabled('mlb', new Date('2026-08-30T12:00:00Z'))).toBe(false);
|
|
});
|
|
|
|
test('a sport without a verified event resolver cannot be canaried into safety', () => {
|
|
// Two defences now. The lease refuses to widen at all (one sport, and only
|
|
// a leasable one), AND eventIdentity still refuses to invent identity, so a
|
|
// widened sport would record UNSUPPORTED_SPORT and stay honest regardless.
|
|
const now = new Date('2026-08-30T12:00:00Z');
|
|
const m = load('mlb@2026-08-30T13:00:00Z,wnba@2026-08-30T13:00:00Z');
|
|
expect(m.lineageCanaryEnabled('wnba', now)).toBe(false);
|
|
expect(m.lineageCanaryEnabled('mlb', now)).toBe(false); // the WHOLE config fails closed
|
|
const r = E.resolveEvent('wnba', dhProp('2026-08-17T17:40:00Z'), DH_GAMES);
|
|
expect(r.canonical_event_id).toBeNull();
|
|
expect(r.event_identity_method).toBe(E.IDENTITY_METHOD.UNSUPPORTED_SPORT);
|
|
});
|
|
|
|
test('the commit is gated on the flag in the live path', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8');
|
|
expect(src).toMatch(/commitPublication && persistedRows && lineageCanaryEnabled\(sp\)/);
|
|
});
|
|
});
|
|
|
|
describe('UNRESOLVED EVENT SAFETY — may lose coverage, may never merge', () => {
|
|
const gs = require('../../src/services/gradeSlateService');
|
|
const dedupe = (props, n) => gs.__internals.dedupeProps(props, n);
|
|
const base = (o) => ({
|
|
player: 'Andre Pallante', stat_type: 'strikeouts', line: 4.5, book: 'draftkings',
|
|
home_team: 'Cincinnati Reds', away_team: 'St. Louis Cardinals',
|
|
game_date: '2026-08-17', ...o,
|
|
});
|
|
|
|
test('an UNRESOLVED MLB prop never merges with another — it fractures', () => {
|
|
// Two rows that could be two different games. Merging them would be the
|
|
// exact defect this change removes, so they must both survive.
|
|
const props = [
|
|
base({ event_identity_method: 'UNRESOLVED' }),
|
|
base({ event_identity_method: 'UNRESOLVED', book: 'fanduel' }),
|
|
];
|
|
expect(dedupe(props, 100)).toHaveLength(2);
|
|
});
|
|
|
|
test('the unresolved key is NOT date+teams and NOT the legacy label', () => {
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8');
|
|
const fn = src.slice(src.indexOf('function propositionEventKey'), src.indexOf('function dedupeProps'));
|
|
// The legacy label is reachable ONLY for sports with no resolver.
|
|
expect(fn).toMatch(/expectsCanonical/);
|
|
expect(fn).toMatch(/unresolved-event:/);
|
|
// And the fracture branch is returned BEFORE the legacy label is built.
|
|
expect(fn.indexOf('unresolved-event:')).toBeLessThan(fn.indexOf('legacy:'));
|
|
});
|
|
|
|
test('a resolved event still collapses books — the real dedupe job survives', () => {
|
|
const props = ['draftkings', 'fanduel', 'betmgm'].map((book) =>
|
|
base({ book, canonical_event_id: 'mlb:gamepk:824514' }));
|
|
expect(dedupe(props, 100)).toHaveLength(1);
|
|
});
|
|
|
|
test('a sport with NO resolver keeps legacy behaviour, unchanged', () => {
|
|
// WNBA never had canonical identity; fracturing it would multiply grading
|
|
// load for no safety gain, and its lineage is excluded from the canary.
|
|
const props = [
|
|
base({ event_identity_method: 'UNSUPPORTED_SPORT' }),
|
|
base({ event_identity_method: 'UNSUPPORTED_SPORT', book: 'fanduel' }),
|
|
];
|
|
expect(dedupe(props, 100)).toHaveLength(1);
|
|
});
|
|
|
|
test('unresolved MLB props from two REAL games cannot collapse', () => {
|
|
// Same player/stat/line/teams/date, identity unknown for both.
|
|
const g1 = base({ event_identity_method: 'UNRESOLVED', game_time: '2026-08-17T17:40:00Z' });
|
|
const g2 = base({ event_identity_method: 'UNRESOLVED', game_time: '2026-08-17T22:40:00Z' });
|
|
expect(dedupe([g1, g2], 100)).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('EXACT PARITY-GAP IDENTITY', () => {
|
|
const served = () => ({
|
|
sport: 'mlb', snapshot_id: 'snap-1', game_date: '2026-08-17', player_key: 'judge',
|
|
stat: 'hits', side: 'over', line: 0.5, game_id: 'g',
|
|
canonical_event_id: 'mlb:gamepk:824514', captured_at: '2026-08-17T14:00:00Z',
|
|
grade: 'B', p_win: 0.61, published: true,
|
|
});
|
|
|
|
test('a failed commit names WHICH served claim is missing', async () => {
|
|
const failing = () => ({ from: () => ({ upsert: async () => ({ error: { message: 'db down' } }) }) });
|
|
const out = await retention.commitPublication(
|
|
{ rows: [served()], publicationId: 'slate-9', publishedAt: '2026-08-17T15:00:00Z' },
|
|
{ getClient: failing, fetchExisting: async () => [], mintReadId: () => 'R1', now: () => 't5' },
|
|
);
|
|
expect(out.parity_gap).toBeTruthy();
|
|
expect(out.parity_gap.reason).toBe('db down');
|
|
expect(out.parity_gap.missing_count).toBe(1);
|
|
const m = out.parity_gap.missing[0];
|
|
// Deterministic recovery, not "lineage failed".
|
|
for (const k of ['snapshot_id', 'player_key', 'stat', 'line', 'side', 'canonical_event_id', 'captured_at']) {
|
|
expect(m[k]).not.toBeNull();
|
|
}
|
|
expect(out.parity_gap.publication_id).toBe('slate-9');
|
|
expect(out.parity_gap.published_at).toBe('2026-08-17T15:00:00Z');
|
|
expect(out.parity_gap.attempted_at).toBe('t5');
|
|
});
|
|
|
|
test('a clean commit records NO parity gap', async () => {
|
|
const ok = () => ({ from: () => ({ upsert: async () => ({ error: null }) }) });
|
|
const out = await retention.commitPublication(
|
|
{ rows: [served()], publicationId: 's', publishedAt: 't' },
|
|
{ getClient: ok, fetchExisting: async () => [], mintReadId: () => 'R1' },
|
|
);
|
|
expect(out.parity_gap).toBeUndefined();
|
|
expect(out.published).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('PROPAGATION — canonical identity survives every hop', () => {
|
|
test('the graded base carries identity from the prop', () => {
|
|
// Source assertion on the ONE place the prop→base copy happens. A test that
|
|
// only built props with the field already set could not catch it being
|
|
// dropped here, which is exactly what the teeth run exposed.
|
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8');
|
|
const base = src.slice(src.indexOf('const base = {'), src.indexOf('const sides = await'));
|
|
expect(base).toMatch(/canonical_event_id:\s*prop\.canonical_event_id/);
|
|
expect(base).toMatch(/event_identity_method:\s*prop\.event_identity_method/);
|
|
expect(base).not.toMatch(/canonical_event_id:\s*null\s*,/);
|
|
});
|
|
|
|
test('the retention row carries identity from the graded base', () => {
|
|
const c = retention.createCollector({
|
|
snapshotId: 's1', capturedAt: '2026-08-17T14:00:00Z',
|
|
gameDate: '2026-08-17', gameIdFor: () => 'mlb:2026-08-17:StL@Cin',
|
|
});
|
|
const base = {
|
|
player: 'Andre Pallante', stat_type: 'strikeouts', line: 4.5, sport: 'mlb',
|
|
canonical_event_id: 'mlb:gamepk:824514',
|
|
event_identity_source: 'MLB_STATSAPI_GAMEPK',
|
|
event_identity_method: 'CANONICAL',
|
|
event_identity_version: 'evid@1',
|
|
event_occurrence: 1,
|
|
};
|
|
c.onGraded(base, [{ ...base, direction: 'over', grade: 'B', confidence: 60 }]);
|
|
expect(c.rows).toHaveLength(1);
|
|
expect(c.rows[0].canonical_event_id).toBe('mlb:gamepk:824514');
|
|
expect(c.rows[0].event_identity_source).toBe('MLB_STATSAPI_GAMEPK');
|
|
expect(c.rows[0].event_identity_method).toBe('CANONICAL');
|
|
expect(c.rows[0].event_occurrence).toBe(1);
|
|
});
|
|
|
|
test('a row with no identity carries NULL, never a fabricated id', () => {
|
|
const c = retention.createCollector({
|
|
snapshotId: 's1', capturedAt: 't', gameDate: '2026-08-17', gameIdFor: () => 'g',
|
|
});
|
|
const base = { player: 'X', stat_type: 'hits', line: 0.5, sport: 'wnba' };
|
|
c.onGraded(base, [{ ...base, direction: 'over', grade: 'B' }]);
|
|
expect(c.rows[0].canonical_event_id).toBeNull();
|
|
expect(c.rows[0].event_identity_method).toBeNull();
|
|
});
|
|
|
|
test('lineage keys on the canonical id once it arrives', () => {
|
|
const row = {
|
|
sport: 'mlb', game_date: '2026-08-17', player_key: 'andre pallante',
|
|
stat: 'strikeouts', side: 'over', line: 4.5, game_id: 'mlb:2026-08-17:StL@Cin',
|
|
captured_at: 't', published: true, grade: 'B', p_win: 0.6,
|
|
canonical_event_id: 'mlb:gamepk:824514',
|
|
};
|
|
expect(L.eventOccurrence(row)).toBe('mlb:gamepk:824514');
|
|
expect(L.readNaturalKey(row)).toContain('mlb:gamepk:824514');
|
|
const dropped = { ...row, canonical_event_id: null };
|
|
expect(L.readNaturalKey(dropped)).not.toContain('gamepk');
|
|
});
|
|
});
|
|
|
|
describe('IMPOSSIBLE-BINDING INVARIANT — the two verified regressions', () => {
|
|
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 } },
|
|
];
|
|
// Teams verified from statsapi: Joe Mack id 691788 Marlins, Yandy Diaz id 650490 Rays.
|
|
const TEAMS = new Map([
|
|
[nameKey('Joe Mack'), new Set(['MIA'])],
|
|
[nameKey('Yandy Diaz'), new Set(['TB'])],
|
|
]);
|
|
|
|
test('JOE MACK regression — Marlin bound to Dodgers@Braves is REFUSED', () => {
|
|
const prop = {
|
|
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',
|
|
};
|
|
const r = E.resolveEvent('mlb', prop, SLATE, TEAMS);
|
|
expect(r.canonical_event_id).toBeNull();
|
|
expect(r.event_identity_method).toBe(E.IDENTITY_METHOD.UNRESOLVED);
|
|
expect(r.reason).toBe('impossible_binding');
|
|
expect(r.evidence).toMatchObject({ violated: true, player_teams: ['MIA'] });
|
|
expect(r.evidence.event_teams).toEqual(['LAD', 'ATL']);
|
|
});
|
|
|
|
test('YANDY DIAZ regression — Ray bound to Rangers@WhiteSox is REFUSED', () => {
|
|
const prop = {
|
|
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',
|
|
};
|
|
const r = E.resolveEvent('mlb', prop, SLATE, TEAMS);
|
|
expect(r.reason).toBe('impossible_binding');
|
|
expect(r.evidence.player_teams).toEqual(['TB']);
|
|
});
|
|
|
|
test('each player STILL binds to his own real game', () => {
|
|
const mack = E.resolveEvent('mlb', {
|
|
player: 'Joe Mack', home_team: 'Miami Marlins', away_team: 'Boston Red Sox',
|
|
game_time: '2026-08-26T22:40:00Z',
|
|
}, SLATE, TEAMS);
|
|
expect(mack.canonical_event_id).toBe('mlb:gamepk:823825');
|
|
|
|
const diaz = E.resolveEvent('mlb', {
|
|
player: 'Yandy Diaz', home_team: 'Detroit Tigers', away_team: 'Tampa Bay Rays',
|
|
game_time: '2026-08-26T17:10:00Z',
|
|
}, SLATE, TEAMS);
|
|
expect(diaz.canonical_event_id).toBe('mlb:gamepk:824234');
|
|
});
|
|
|
|
test('UNKNOWN team preserves uncertainty — it is not a violation', () => {
|
|
// Refusing on absent evidence would drop real props for a data gap.
|
|
const r = E.resolveEvent('mlb', {
|
|
player: 'Someone Unlisted', home_team: 'Atlanta Braves',
|
|
away_team: 'Los Angeles Dodgers', game_time: '2026-08-26T23:15:00Z',
|
|
}, SLATE, TEAMS);
|
|
expect(r.canonical_event_id).toBe('mlb:gamepk:824878');
|
|
});
|
|
|
|
test('no index at all leaves behaviour exactly as before', () => {
|
|
const prop = {
|
|
player: 'Joe Mack', home_team: 'Atlanta Braves', away_team: 'Los Angeles Dodgers',
|
|
game_time: '2026-08-26T23:15:00Z',
|
|
};
|
|
expect(E.resolveEvent('mlb', prop, SLATE).canonical_event_id).toBe('mlb:gamepk:824878');
|
|
expect(E.resolveEvent('mlb', prop, SLATE, null).canonical_event_id).toBe('mlb:gamepk:824878');
|
|
});
|
|
|
|
test('time proximity CANNOT override an impossible team relationship', () => {
|
|
// The mis-nested event is the closest by start time; it must still refuse.
|
|
const prop = {
|
|
player: 'Joe Mack', home_team: 'Atlanta Braves', away_team: 'Los Angeles Dodgers',
|
|
game_time: '2026-08-26T23:15:00Z',
|
|
};
|
|
expect(E.resolveEvent('mlb', prop, SLATE, TEAMS).reason).toBe('impossible_binding');
|
|
});
|
|
|
|
test('a multi-team player (in-season trade) is not falsely refused', () => {
|
|
const traded = new Map([[nameKey('Joe Mack'), new Set(['MIA', 'ATL'])]]);
|
|
const r = E.resolveEvent('mlb', {
|
|
player: 'Joe Mack', home_team: 'Atlanta Braves', away_team: 'Los Angeles Dodgers',
|
|
game_time: '2026-08-26T23:15:00Z',
|
|
}, SLATE, traded);
|
|
expect(r.canonical_event_id).toBe('mlb:gamepk:824878');
|
|
});
|
|
|
|
test('the roster index keys on nameKey, not the raw name', () => {
|
|
// nameKey('Joe Mack') is 'joseph mack' — the nickname table. An index keyed
|
|
// on the raw string silently matches nothing and the invariant never fires.
|
|
expect(nameKey('Joe Mack')).toBe('joseph mack');
|
|
const rawKeyed = new Map([['joe mack', new Set(['MIA'])]]);
|
|
const r = E.resolveEvent('mlb', {
|
|
player: 'Joe Mack', home_team: 'Atlanta Braves', away_team: 'Los Angeles Dodgers',
|
|
game_time: '2026-08-26T23:15:00Z',
|
|
}, SLATE, rawKeyed);
|
|
expect(r.canonical_event_id).toBe('mlb:gamepk:824878'); // does NOT fire
|
|
});
|
|
|
|
test('buildPlayerTeamIndex keys correctly and survives an unreadable roster', async () => {
|
|
const built = await E.buildPlayerTeamIndex(SLATE, {
|
|
getTeamRoster: async (id) => {
|
|
if (id === 146) return [{ id: 691788, name: 'Joe Mack' }];
|
|
if (id === 144) throw new Error('roster unavailable');
|
|
return [];
|
|
},
|
|
});
|
|
expect(built.index.get(nameKey('Joe Mack'))).toEqual(new Set(['MIA']));
|
|
expect(built.stats.failed).toBe(1);
|
|
expect(built.stats.teams).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('no roster source yields an empty index, never a fabricated one', async () => {
|
|
const built = await E.buildPlayerTeamIndex(SLATE, {});
|
|
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);
|
|
});
|
|
});
|