MLB canonical event identity, impossible-binding refusal, event-aware dedupe, publication commit
Release-isolated slice built from 41ba38e. Ships ONLY the event-integrity +
publication + lineage-canary closure; the 90-path development tree stays
undeployed.
- canonical MLB event identity from statsapi gamePk (mlb:gamepk:<pk>), with
event_identity_source/method/version recorded. The id is canonical; the
binding is derived and says so.
- IMPOSSIBLE-BINDING REFUSAL. Verified in prod 2026-08-26: Joe Mack (Marlins)
was bound to Dodgers@Braves and Yandy Diaz (Rays) to Rangers@WhiteSox, both
from one book in the 01:00/03:01 UTC cycles after their own games began. Root
cause is source market data, not the binder. A prop whose player's team is not
an event participant now refuses; unknown team preserves uncertainty.
- event-aware dedupe: books still collapse, events no longer do. An unresolved
MLB event fractures rather than falling back to the collision-prone
date+teams key.
- publication commit moved AFTER the authoritative Redis slate write, with
exact parity-gap identity when the product publishes and the record does not.
- lineage dual-write behind LINEAGE_CANARY_SPORTS, DISABLED for this deploy.
Excluded deliberately: WNBA feed/chain, market ontology, PerformanceDistribution,
calibration certification, truth diagnostics, applyRevision Phase-1, and the
analyzeViaEngine1 confidence-rounding change (a served field).
Suite 380/5,040/0 from this worktree; web tsc exit 0; champion output identical
to production.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* PUBLICATION LINEAGE — the semantic gates.
|
||||
*
|
||||
* The central finding these lock down: a model_snapshots row is NOT a published
|
||||
* Read. `gradeSlateService` fires its retention hook with BOTH sides, graded AND
|
||||
* refused, before any filtering, and only the higher-confidence graded side
|
||||
* becomes the served Read. Measured on mlb 2026-08-26: 8,443 of 13,012 captured
|
||||
* rows (64.9%) describe a state no user was ever shown.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const L = require('../../src/services/read/readLineage');
|
||||
const retention = require('../../src/services/retentionService');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
const CLAIM = {
|
||||
sport: 'mlb', game_date: '2026-08-27', player_key: 'aaron judge',
|
||||
stat: 'hits', side: 'over', line: 0.5,
|
||||
game_id: 'mlb:2026-08-27:BostonRedSox@NewYorkYankees',
|
||||
captured_at: '2026-08-27T14:00:00Z',
|
||||
grade: 'B', p_win: 0.61, confidence: 61, projection: 1.2,
|
||||
over_odds: 120, under_odds: -145, book: 'dk', ev_pct: 3.2, takeable: true,
|
||||
published: true,
|
||||
};
|
||||
|
||||
const persisted = (id, claim, res) => ({
|
||||
id, read_id: res.read_id, read_natural_key: res.read_natural_key,
|
||||
game_id: claim.game_id, claim_digest: res.claim_digest,
|
||||
revision_ordinal: res.revision_ordinal, lineage_action: res.action,
|
||||
supersedes_id: res.supersedes_id ?? null, captured_at: claim.captured_at,
|
||||
claim,
|
||||
});
|
||||
const mint = (v) => () => v;
|
||||
|
||||
describe('PUBLICATION MAPPING — a capture is not a publication', () => {
|
||||
test('an unmarked capture is refused as not_published', () => {
|
||||
const r = L.resolveLineage({ candidate: { ...CLAIM, published: false }, existing: [] });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.refused).toBe('not_published');
|
||||
});
|
||||
|
||||
test('publication is opt-IN — an absent marker is not a publication', () => {
|
||||
const noMarker = { ...CLAIM };
|
||||
delete noMarker.published;
|
||||
expect(L.resolveLineage({ candidate: noMarker, existing: [] }).refused).toBe('not_published');
|
||||
});
|
||||
|
||||
test('the collector marks ONLY the side that became the served Read', () => {
|
||||
const c = retention.createCollector({
|
||||
snapshotId: 's1', capturedAt: '2026-08-27T14:00:00Z',
|
||||
gameDate: '2026-08-27', gameIdFor: () => 'mlb:2026-08-27:BOS@NYY',
|
||||
});
|
||||
const base = { player: 'Aaron Judge', stat_type: 'hits', line: 0.5, sport: 'mlb', book: 'dk' };
|
||||
const over = { ...base, direction: 'over', grade: 'B', confidence: 61, p_win: 0.61 };
|
||||
const under = { ...base, direction: 'under', grade: 'C', confidence: 39, p_win: 0.39 };
|
||||
|
||||
c.onGraded(base, [over, under]);
|
||||
expect(c.rows).toHaveLength(2);
|
||||
expect(c.rows.filter((r) => r.published)).toHaveLength(0);
|
||||
|
||||
c.onPublished(base, over);
|
||||
expect(c.rows.filter((r) => r.published).map((r) => r.side)).toEqual(['over']);
|
||||
expect(c.rows.filter((r) => !r.published).map((r) => r.side)).toEqual(['under']);
|
||||
});
|
||||
|
||||
test('every collected row DECLARES published, defaulting false', () => {
|
||||
const c = retention.createCollector({
|
||||
snapshotId: 's1', capturedAt: '2026-08-27T14:00:00Z',
|
||||
gameDate: '2026-08-27', gameIdFor: () => 'g',
|
||||
});
|
||||
c.onGraded({ player: 'X', stat_type: 'hits', line: 0.5, sport: 'mlb' },
|
||||
[{ player: 'X', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'B' }]);
|
||||
for (const r of c.rows) {
|
||||
expect('published' in r).toBe(true);
|
||||
expect(r.published).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('a refusal is never published even if the signal is misfired at it', () => {
|
||||
const c = retention.createCollector({
|
||||
snapshotId: 's1', capturedAt: '2026-08-27T14:00:00Z',
|
||||
gameDate: '2026-08-27', gameIdFor: () => 'g',
|
||||
});
|
||||
const base = { player: 'X', stat_type: 'hits', line: 0.5, sport: 'mlb' };
|
||||
const refused = { ...base, direction: 'over', insufficient_data: true };
|
||||
c.onGraded(base, [refused]);
|
||||
expect(c.rows[0].refused).toBe(true);
|
||||
// Even marked, a refusal cannot form a published claim: the resolver still
|
||||
// needs a claim, and the slate never served this.
|
||||
c.onPublished(base, refused);
|
||||
expect(c.rows[0].published).toBe(true); // the signal is literal…
|
||||
// …but the product never fires it for a refusal — the winner is chosen from
|
||||
// `cands`, which excludes refusals. Locked by the source assertion below.
|
||||
const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8');
|
||||
expect(src).toMatch(/const cands = sides\.filter\(\(s\) => s && s\.grade && !s\.insufficient_data\)/);
|
||||
expect(src.indexOf('onPublished')).toBeGreaterThan(src.indexOf('const winner'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHANGE SEMANTICS — belief, market and comparison are distinct', () => {
|
||||
const base = { line: 0.5, side: 'over', book: 'dk', over_odds: 120, under_odds: -145, p_win: 0.61, grade: 'B', ev_pct: 3.2, takeable: true };
|
||||
const t = (patch) => L.classifyChange(base, { ...base, ...patch });
|
||||
|
||||
test.each([
|
||||
['price only', { over_odds: 180, ev_pct: 5.1 }, 'MARKET_REPRICE', false, true],
|
||||
['under price only', { under_odds: -200, ev_pct: 4.0 }, 'MARKET_REPRICE', false, true],
|
||||
['line change', { line: 1.5, ev_pct: 1.0 }, 'MARKET_LINE_CHANGE', false, true],
|
||||
['book change', { book: 'fd' }, 'MARKET_REPRICE', false, true],
|
||||
['probability', { p_win: 0.55 }, 'BELIEF_CHANGE', true, false],
|
||||
['grade', { grade: 'C' }, 'BELIEF_CHANGE', true, false],
|
||||
['projection', { projection: 2.1 }, 'BELIEF_CHANGE', true, false],
|
||||
['comparison only', { ev_pct: 5.0 }, 'COMPARISON_CHANGE', false, false],
|
||||
['takeable only', { takeable: false }, 'COMPARISON_CHANGE', false, false],
|
||||
['belief + market', { p_win: 0.55, over_odds: 180 }, 'MIXED_CHANGE', true, true],
|
||||
['provenance only', { confidence_basis: 'x' }, 'PROVENANCE_CHANGE', false, false],
|
||||
['nothing', {}, 'NO_MATERIAL_PUBLISHED_CHANGE', false, false],
|
||||
])('%s -> %s', (_l, patch, expected, belief, market) => {
|
||||
const r = t(patch);
|
||||
expect(r.change_type).toBe(expected);
|
||||
expect(r.belief_changed).toBe(belief);
|
||||
expect(r.market_changed).toBe(market);
|
||||
});
|
||||
|
||||
test('a model version change with identical numbers is NOT a belief change', () => {
|
||||
// The output is what was published. A rebuild that produces the same number
|
||||
// did not change what the user was told.
|
||||
expect(t({}).change_type).toBe('NO_MATERIAL_PUBLISHED_CHANGE');
|
||||
expect(L.CLAIM_FIELD_CLASSES.model_version).toBeUndefined();
|
||||
});
|
||||
|
||||
test('COMPARISON never masquerades as BELIEF', () => {
|
||||
// ev_pct moves whenever either belief or market moves; promoting it would
|
||||
// report every reprice as a changed opinion.
|
||||
for (const f of ['ev_pct', 'edge_pct', 'value', 'kelly', 'takeable']) {
|
||||
expect(L.CLAIM_FIELD_CLASSES[f]).toBe(L.FIELD_CLASS.COMPARISON);
|
||||
}
|
||||
});
|
||||
|
||||
test('the resolver stamps change_type onto a real revision', () => {
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
expect(r1.change_type).toBe(L.CHANGE_TYPE.INITIAL_PUBLICATION);
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const repriced = { ...CLAIM, over_odds: 180, captured_at: '2026-08-27T19:00:00Z' };
|
||||
const r2 = L.resolveLineage({ candidate: repriced, existing: [row1] });
|
||||
expect(r2.change_type).toBe(L.CHANGE_TYPE.MARKET_REPRICE);
|
||||
expect(r2.belief_changed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLAIM VERSIONING', () => {
|
||||
test('every resolution stamps the claim and digest versions', () => {
|
||||
const r = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
expect(r.claim_schema_version).toBe(L.CLAIM_SCHEMA_VERSION);
|
||||
expect(r.digest_algorithm_version).toBe(L.DIGEST_ALGORITHM_VERSION);
|
||||
expect(L.CLAIM_SCHEMA_VERSION).toMatch(/@\d+$/);
|
||||
});
|
||||
|
||||
test('key ORDER cannot change a digest', () => {
|
||||
const a = { line: 0.5, side: 'over', p_win: 0.61, grade: 'B', over_odds: 120 };
|
||||
const b = { over_odds: 120, grade: 'B', p_win: 0.61, side: 'over', line: 0.5 };
|
||||
expect(L.claimDigest(a)).toBe(L.claimDigest(b));
|
||||
});
|
||||
|
||||
test('BOTH price spellings are in the claim, so neither store is blind', () => {
|
||||
for (const f of ['locked_odds', 'over_odds', 'under_odds']) {
|
||||
expect(L.CLAIM_MARKET_FIELDS).toContain(f);
|
||||
}
|
||||
const ms = { line: 0.5, side: 'over', over_odds: 120, under_odds: -145 };
|
||||
expect(L.claimDigest(ms)).not.toBe(L.claimDigest({ ...ms, over_odds: 180 }));
|
||||
expect(L.claimDigest(ms)).not.toBe(L.claimDigest({ ...ms, under_odds: -200 }));
|
||||
const led = { line: 0.5, side: 'over', locked_odds: 120 };
|
||||
expect(L.claimDigest(led)).not.toBe(L.claimDigest({ ...led, locked_odds: 180 }));
|
||||
});
|
||||
|
||||
test('a post-hoc settlement field never moves the claim digest', () => {
|
||||
const base = L.claimDigest(CLAIM);
|
||||
for (const f of L.POST_HOC_FIELDS) {
|
||||
expect(L.claimDigest({ ...CLAIM, [f]: 'anything' })).toBe(base);
|
||||
}
|
||||
});
|
||||
|
||||
test('an internal non-claim field never moves the digest', () => {
|
||||
const base = L.claimDigest(CLAIM);
|
||||
for (const f of ['snapshot_id', 'captured_at', 'code_sha', 'cycle_hour_utc', 'chain_shadow']) {
|
||||
expect(L.claimDigest({ ...CLAIM, [f]: 'zzz' })).toBe(base);
|
||||
}
|
||||
});
|
||||
|
||||
test('a legacy row with no version stamp stays truthfully unknown', () => {
|
||||
const legacy = {
|
||||
id: 55, read_id: null, game_id: CLAIM.game_id, claim_digest: null,
|
||||
revision_ordinal: null, lineage_action: null, supersedes_id: null,
|
||||
captured_at: '2026-08-01T14:00:00Z',
|
||||
};
|
||||
const r = L.resolveLineage({ candidate: CLAIM, existing: [legacy], mintReadId: mint('R9') });
|
||||
expect(r.lineage_state).toBe(L.LINEAGE_STATE.LEGACY_UNVERIFIED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SETTLEMENT SEPARATION', () => {
|
||||
test("the REAL settle patch is confined to evaluation fields", () => {
|
||||
const real = {
|
||||
outcome: 'hit', actual_value: 2, settled_at: '2026-08-28T02:00:00Z',
|
||||
settlement_source: 'statsapi_boxscore',
|
||||
};
|
||||
expect(L.settlementUpdateIsSafe(real)).toMatchObject({ safe: true, violations: [] });
|
||||
});
|
||||
|
||||
test('a patch touching ANY claim-owned field is refused and names it', () => {
|
||||
for (const f of ['grade', 'p_win', 'line', 'over_odds', 'claim_digest', 'published', 'revision_ordinal']) {
|
||||
const r = L.settlementUpdateIsSafe({ outcome: 'hit', [f]: 'x' });
|
||||
expect(r.safe).toBe(false);
|
||||
expect(r.violations).toContain(f);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unrecognised key is reported, not assumed safe', () => {
|
||||
expect(L.settlementUpdateIsSafe({ outcome: 'hit', mystery: 1 }).unrecognised).toEqual(['mystery']);
|
||||
});
|
||||
|
||||
test('the production settle path writes only evaluation fields', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotSettlementService.js'), 'utf8');
|
||||
const m = src.match(/\.update\(\{([\s\S]*?)\}\)/);
|
||||
expect(m).toBeTruthy();
|
||||
const keys = [...m[1].matchAll(/^\s*([a-z_0-9]+)\s*:/gm)].map((x) => x[1]);
|
||||
expect(keys.length).toBeGreaterThan(0);
|
||||
expect(L.settlementUpdateIsSafe(Object.fromEntries(keys.map((k) => [k, 1])))).toMatchObject({ safe: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DUAL-WRITE FAILURE CONTRACT', () => {
|
||||
const rows = () => ([{ ...CLAIM }, { ...CLAIM, side: 'under', published: false }]);
|
||||
|
||||
test('legacy OK + lineage FAILS: rows persist, gap is measurable', async () => {
|
||||
const r = rows();
|
||||
const out = await retention.attachLineage(r, {
|
||||
fetchExisting: async () => { throw new Error('lineage db down'); },
|
||||
});
|
||||
expect(out.error).toBe('lineage db down');
|
||||
for (const row of r) expect(row.read_id).toBeNull();
|
||||
for (const row of r) for (const k of retention.LINEAGE_KEYS) expect(k in row).toBe(true);
|
||||
});
|
||||
|
||||
test('a failed publication cannot produce lineage — the ordering forbids it', () => {
|
||||
// SUPERSEDED MECHANISM, SAME INVARIANT. Lineage used to resolve inside
|
||||
// `persist()`, which runs BEFORE the authoritative slate write — so it could
|
||||
// have recorded a publication for a slate Redis never served. Lineage now
|
||||
// resolves only in `commitPublication`, which is called after that write
|
||||
// succeeds, and only over rows already marked published.
|
||||
const ret = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8');
|
||||
const persistBody = ret.slice(ret.indexOf('async function persist('));
|
||||
// Capture no longer resolves lineage at all.
|
||||
expect(persistBody).not.toMatch(/attachLineage\(/);
|
||||
// Exactly one table write inside persist, and it is model_snapshots.
|
||||
const writes = [...persistBody.matchAll(/\.from\('([a-z_]+)'\)/g)].map((m) => m[1]);
|
||||
expect(new Set(writes)).toEqual(new Set(['model_snapshots']));
|
||||
|
||||
// And the commit is downstream of the authoritative write.
|
||||
const snap = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8');
|
||||
expect(snap.indexOf('cacheSet(`snapshot:${sp}:latest`'))
|
||||
.toBeLessThan(snap.indexOf('commitPublication({'));
|
||||
});
|
||||
|
||||
test('an unpublished capture is counted separately from a failure', async () => {
|
||||
const r = rows();
|
||||
const out = await retention.attachLineage(r, { fetchExisting: async () => [], mintReadId: mint('R1') });
|
||||
expect(out.not_published).toBe(1);
|
||||
expect(out.refused).toBe(0);
|
||||
expect(out.origins).toBe(1);
|
||||
});
|
||||
|
||||
test('no database configured leaves NULL lineage without throwing', async () => {
|
||||
const r = rows();
|
||||
const out = await retention.attachLineage(r, { fetchExisting: async () => null });
|
||||
expect(out.error).toBeNull();
|
||||
for (const row of r) expect(row.read_id).toBeNull();
|
||||
});
|
||||
|
||||
test('malformed identity is counted as unresolved, not published', async () => {
|
||||
const bad = [{ ...CLAIM, player_key: null }];
|
||||
const out = await retention.attachLineage(bad, { fetchExisting: async () => [] });
|
||||
expect(out.unresolved).toBe(1);
|
||||
expect(out.origins).toBe(0);
|
||||
});
|
||||
|
||||
test('change types are counted for telemetry', async () => {
|
||||
const r = [
|
||||
{ ...CLAIM },
|
||||
{ ...CLAIM, captured_at: '2026-08-27T19:00:00Z', over_odds: 180 },
|
||||
{ ...CLAIM, captured_at: '2026-08-27T22:00:00Z', over_odds: 180, p_win: 0.55, grade: 'C' },
|
||||
];
|
||||
const out = await retention.attachLineage(r, { fetchExisting: async () => [], mintReadId: mint('R1') });
|
||||
expect(out.change_types.INITIAL_PUBLICATION).toBe(1);
|
||||
expect(out.change_types.MARKET_REPRICE).toBe(1);
|
||||
expect(out.change_types.BELIEF_CHANGE).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NON-AUTHORITY — lineage cannot serve the product', () => {
|
||||
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;
|
||||
};
|
||||
|
||||
test('no product path imports the lineage resolver', () => {
|
||||
const allowed = new Set([
|
||||
path.join(ROOT, 'src/services/read/readLineage.js'),
|
||||
path.join(ROOT, 'src/services/retentionService.js'),
|
||||
]);
|
||||
const files = [
|
||||
...walk(path.join(ROOT, 'src')),
|
||||
...(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['"`]/.test(src);
|
||||
}).map((f) => path.relative(ROOT, f));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
test('no route or web file reads a publication-semantics column', () => {
|
||||
const cols = ['claim_digest', 'revision_ordinal', 'lineage_action', 'change_type',
|
||||
'claim_schema_version', 'read_natural_key'];
|
||||
const roots = [path.join(ROOT, 'src/routes')];
|
||||
if (fs.existsSync(path.join(ROOT, 'web/src'))) roots.push(path.join(ROOT, 'web/src'));
|
||||
const hits = [];
|
||||
for (const r of roots) {
|
||||
for (const f of walk(r)) {
|
||||
const src = fs.readFileSync(f, 'utf8');
|
||||
for (const c of cols) if (src.includes(c)) hits.push(`${path.relative(ROOT, f)} -> ${c}`);
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
|
||||
test('the served envelope comes from the cache, not from model_snapshots', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'src/routes/snapshot.js'), 'utf8');
|
||||
expect(src).toMatch(/cacheGet/);
|
||||
expect(src).not.toMatch(/model_snapshots/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EVENT IDENTITY — doubleheader and same-day occurrence', () => {
|
||||
const g = { ...CLAIM };
|
||||
|
||||
test('TODAY identity is DERIVED_TEAM_PAIR and says so', () => {
|
||||
expect(L.identityMethod(g)).toBe(L.IDENTITY_METHOD.DERIVED_TEAM_PAIR);
|
||||
expect(L.eventOccurrence(g)).toBeNull();
|
||||
const r = L.resolveLineage({ candidate: g, existing: [], mintReadId: mint('R1') });
|
||||
expect(r.identity_method).toBe(L.IDENTITY_METHOD.DERIVED_TEAM_PAIR);
|
||||
expect(r.identity_version).toBe(L.IDENTITY_VERSION);
|
||||
});
|
||||
|
||||
test('a canonical occurrence SEPARATES two games of a doubleheader', () => {
|
||||
const g1 = { ...g, game_pk: 776001 };
|
||||
const g2 = { ...g, game_pk: 776002 };
|
||||
expect(L.readNaturalKey(g1)).not.toBe(L.readNaturalKey(g2));
|
||||
const r1 = L.resolveLineage({ candidate: g1, existing: [], mintReadId: mint('RA') });
|
||||
const row1 = persisted(101, g1, r1);
|
||||
row1.claim = g1;
|
||||
const r2 = L.resolveLineage({ candidate: g2, existing: [row1], mintReadId: mint('RB') });
|
||||
expect(r2.read_id).toBe('RB');
|
||||
expect(r2.action).toBe(L.LINEAGE_ACTION.ORIGIN);
|
||||
expect(r2.identity_method).toBe(L.IDENTITY_METHOD.CANONICAL_EVENT_ID);
|
||||
});
|
||||
|
||||
test('a canonical occurrence OVERRIDES the team-name heuristic entirely', () => {
|
||||
// Same teams, same spelling, different real game: the heuristic would merge
|
||||
// these; the occurrence must win.
|
||||
const g1 = { ...g, game_pk: 1 };
|
||||
const g2 = { ...g, game_pk: 2 };
|
||||
expect(L.sameEvent(g1.game_id, g2.game_id)).toBe(true); // heuristic says same
|
||||
const r1 = L.resolveLineage({ candidate: g1, existing: [], mintReadId: mint('RA') });
|
||||
const row1 = { ...persisted(101, g1, r1), claim: g1 };
|
||||
const r2 = L.resolveLineage({ candidate: g2, existing: [row1], mintReadId: mint('RB') });
|
||||
expect(r2.read_id).toBe('RB'); // occurrence overrules it
|
||||
});
|
||||
|
||||
test('KNOWN LIMIT: without an occurrence a doubleheader is indistinguishable', () => {
|
||||
// `ledgerService.gameIdFor` emits sport:date:away@home with NO game number,
|
||||
// so both halves produce a byte-identical id and NO rule over that string
|
||||
// can separate them. Asserted so the limit is visible, not implied.
|
||||
const gameIdFor = require('../../src/services/ledgerService').__internals.gameIdFor;
|
||||
const prop = { away_team: 'Detroit Tigers', home_team: 'Kansas City Royals' };
|
||||
expect(gameIdFor('mlb', prop, '2026-08-27')).toBe(gameIdFor('mlb', prop, '2026-08-27'));
|
||||
expect(L.readNaturalKey(g)).toBe(L.readNaturalKey({ ...g }));
|
||||
});
|
||||
|
||||
test('the dedupe key now carries the EVENT — the data-loss defect is closed', () => {
|
||||
// PREVIOUSLY: `player::stat_type::line` with no event component, so the
|
||||
// second half of a doubleheader was DROPPED before grading and the merge
|
||||
// was avoided only by losing the game. Now the event leads the key, so both
|
||||
// real games survive. Behavioural proof lives in eventPublication.test.js.
|
||||
const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8');
|
||||
expect(src).toMatch(/const key = `\$\{propositionEventKey\(p\)\}::/);
|
||||
expect(src).not.toMatch(/const key = `\$\{p\.player\}::\$\{p\.stat_type\}::\$\{p\.line\}`/);
|
||||
});
|
||||
|
||||
test('different participants, stats, lines and sides never share a Read', () => {
|
||||
const base = L.readNaturalKey(g);
|
||||
for (const patch of [{ player_key: 'other' }, { stat: 'runs' }, { line: 1.5 }, { side: 'under' }, { sport: 'wnba' }, { game_date: '2026-08-28' }]) {
|
||||
expect(L.readNaturalKey({ ...g, ...patch })).not.toBe(base);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user