Files
vyndr/tests/unit/publicationLineage.test.js
T
builtbykev 352016790a 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
2026-08-27 16:29:38 -04:00

416 lines
19 KiB
JavaScript

'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);
}
});
});