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:
@@ -423,7 +423,15 @@ describe('0 LEAKS — measured against the source, not asserted in prose', () =>
|
||||
});
|
||||
|
||||
it('the ONLY writers are the shadow service, retention and the snapshot', () => {
|
||||
const writers = walk('src').filter((f) => /chain_shadow|chainShadow/.test(read(f)));
|
||||
// The EVALUATOR names the column and must: `gradeFreeze` exists to verify
|
||||
// that the shadow reaches nothing served, and a verifier that cannot say
|
||||
// the name of the thing it verifies is not a verifier. It is excluded from
|
||||
// the writer list and held to the stricter rule below — it may NAME the
|
||||
// column, never assign to it.
|
||||
const EVALUATOR = 'src/services/evaluator/';
|
||||
const writers = walk('src')
|
||||
.filter((f) => /chain_shadow|chainShadow/.test(read(f)))
|
||||
.filter((f) => !f.startsWith(EVALUATOR));
|
||||
expect(writers.sort()).toEqual([
|
||||
'src/services/model/chainShadow.js',
|
||||
'src/services/retentionService.js',
|
||||
@@ -431,6 +439,18 @@ describe('0 LEAKS — measured against the source, not asserted in prose', () =>
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
it('the evaluator NAMES the shadow column but never assigns to it', () => {
|
||||
// Excluding it above is only safe if this holds. Proven, not assumed.
|
||||
const dir = 'src/services/evaluator/';
|
||||
const files = walk('src').filter((f) => f.startsWith(dir));
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
for (const f of files) {
|
||||
const src = read(f);
|
||||
expect(src).not.toMatch(/chain_shadow\s*[:=][^=]/);
|
||||
expect(src).not.toMatch(/\.chain_shadow\s*=/);
|
||||
}
|
||||
});
|
||||
it('the shadow runs with requireCalibrated FALSE and says so where it is stored', () => {
|
||||
expect(read('src/services/model/chainShadow.js')).toMatch(/requireCalibrated:\s*false/);
|
||||
expect(cs.SHADOW_VERSION).toBe('chain-shadow@1');
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
'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.
|
||||
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,
|
||||
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 explicit configuration', () => {
|
||||
const m = load('mlb');
|
||||
expect(m.lineageCanaryEnabled('mlb')).toBe(true);
|
||||
for (const sp of ['wnba', 'nba', 'soccer']) {
|
||||
expect(m.lineageCanaryEnabled(sp)).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')).toBe(false);
|
||||
});
|
||||
|
||||
test('a sport without a verified event resolver cannot be canaried into safety', () => {
|
||||
// Widening the flag does NOT create identity: eventIdentity still refuses,
|
||||
// so a widened sport records UNSUPPORTED_SPORT and its lineage stays honest.
|
||||
const m = load('mlb,wnba');
|
||||
expect(m.lineageCanaryEnabled('wnba')).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* READ LINEAGE — append-only chronology of published claims.
|
||||
*
|
||||
* Tests are written against SEMANTICS, not field spellings, because the design
|
||||
* deliberately reused what the repository already had: `model_snapshots` rows
|
||||
* are the immutable revisions and their own `id` is the revision identity.
|
||||
*/
|
||||
|
||||
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, '..', '..');
|
||||
|
||||
// `published: true` is REQUIRED as of the publication-truth work. A capture is
|
||||
// not a publication: 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.
|
||||
//
|
||||
// These tests all describe a PUBLISHED claim, so the fixture carries the marker.
|
||||
// Coverage of the unmarked case lives in publicationLineage.test.js.
|
||||
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, takeable: true,
|
||||
published: true,
|
||||
};
|
||||
|
||||
/** A persisted row, as the dual-write would have left it. */
|
||||
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,
|
||||
});
|
||||
|
||||
const mint = (v) => () => v;
|
||||
|
||||
describe('ORIGINAL PUBLICATION', () => {
|
||||
test('a new Read creates one logical Read and one immutable initial revision', () => {
|
||||
const r = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.action).toBe(L.LINEAGE_ACTION.ORIGIN);
|
||||
expect(r.read_id).toBe('R1');
|
||||
expect(r.revision_ordinal).toBe(0);
|
||||
expect(r.supersedes_id).toBeNull();
|
||||
expect(r.claim_digest).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
describe('SECOND PUBLICATION', () => {
|
||||
test('a materially different claim creates a second revision pointing at the first', () => {
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const changed = { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' };
|
||||
const r2 = L.resolveLineage({ candidate: changed, existing: [row1] });
|
||||
|
||||
expect(r2.action).toBe(L.LINEAGE_ACTION.REVISION);
|
||||
expect(r2.read_id).toBe('R1');
|
||||
expect(r2.revision_ordinal).toBe(1);
|
||||
expect(r2.supersedes_id).toBe(101);
|
||||
expect(r2.claim_digest).not.toBe(r1.claim_digest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NO MUTATION', () => {
|
||||
test('creating revision 2 leaves revision 1 byte-for-byte unchanged', () => {
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const before = JSON.stringify(row1);
|
||||
L.resolveLineage({
|
||||
candidate: { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' },
|
||||
existing: [row1],
|
||||
});
|
||||
expect(JSON.stringify(row1)).toBe(before);
|
||||
expect(Object.isFrozen(r1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('THIRD REVISION', () => {
|
||||
test('a deterministic chain R1 -> R2 -> R3 forms and walks', () => {
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const c2 = { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' };
|
||||
const r2 = L.resolveLineage({ candidate: c2, existing: [row1] });
|
||||
const row2 = persisted(102, c2, r2);
|
||||
const c3 = { ...CLAIM, grade: 'D', p_win: 0.49, captured_at: '2026-08-27T22:00:00Z' };
|
||||
const r3 = L.resolveLineage({ candidate: c3, existing: [row1, row2] });
|
||||
const row3 = persisted(103, c3, r3);
|
||||
|
||||
expect(r3.revision_ordinal).toBe(2);
|
||||
expect(r3.supersedes_id).toBe(102);
|
||||
|
||||
const chron = L.chronology([row3, row1, row2]);
|
||||
expect(chron.ok).toBe(true);
|
||||
expect(chron.revisions.map((r) => r.revision_ordinal)).toEqual([0, 1, 2]);
|
||||
expect(chron.revision_count).toBe(2);
|
||||
expect(chron.original.id).toBe(101);
|
||||
expect(chron.current.id).toBe(103);
|
||||
});
|
||||
|
||||
test('a broken supersession chain is reported, not silently reordered', () => {
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const orphan = { ...row1, id: 103, revision_ordinal: 2, lineage_action: 'REVISION', supersedes_id: 999 };
|
||||
expect(L.chronology([row1, orphan]).ok).toBe(false);
|
||||
expect(L.chronology([row1, orphan]).reason).toMatch(/broken supersession/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EXACT READ IDENTITY', () => {
|
||||
test('two genuinely different games do not share one logical Read', () => {
|
||||
// Real prod shape: `jac caglianone` on 2026-08-22 under two different
|
||||
// matchups. Same player, stat, line and side; different events.
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const otherGame = { ...CLAIM, game_id: 'mlb:2026-08-27:MinnesotaTwins@SanDiegoPadres' };
|
||||
const r2 = L.resolveLineage({ candidate: otherGame, existing: [row1], mintReadId: mint('R2') });
|
||||
|
||||
expect(r2.action).toBe(L.LINEAGE_ACTION.ORIGIN);
|
||||
expect(r2.read_id).toBe('R2');
|
||||
expect(r2.supersedes_id).toBeNull();
|
||||
});
|
||||
|
||||
test('an incomplete identity refuses rather than keying on a partial tuple', () => {
|
||||
for (const missing of ['sport', 'game_date', 'player_key', 'stat', 'side']) {
|
||||
const bad = { ...CLAIM, [missing]: null };
|
||||
expect(L.resolveLineage({ candidate: bad, existing: [] }).refused).toBe('incomplete_read_identity');
|
||||
}
|
||||
expect(L.readNaturalKey({ ...CLAIM, line: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UNSTABLE GAME-ID SPELLING', () => {
|
||||
test('two spellings of one game stay ONE logical Read', () => {
|
||||
// Measured: 1,328 of 30,746 identity groups carry more than one spelling.
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
// A REAL spelling pair from the data: measured over 52 distinct spelling
|
||||
// pairs in one week, 46 are prefix-compatible like this one.
|
||||
const abbreviated = {
|
||||
...CLAIM, game_id: 'mlb:2026-08-27:BostonRed@NewYork',
|
||||
grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z',
|
||||
};
|
||||
const r2 = L.resolveLineage({ candidate: abbreviated, existing: [row1] });
|
||||
expect(r2.read_id).toBe('R1');
|
||||
expect(r2.action).toBe(L.LINEAGE_ACTION.REVISION);
|
||||
});
|
||||
|
||||
test('the prefix rule matches abbreviations and refuses unrelated pairs', () => {
|
||||
expect(L.sameEvent('nba:2026-10-20:OKC@SAS', 'nba:2026-10-20:OKCThunder@SASpurs')).toBe(true);
|
||||
expect(L.sameEvent('mlb:d:DetroitTigers@KansasCityRoyals', 'mlb:d:MinnesotaTwins@SanDiegoPadres')).toBe(false);
|
||||
// An unparseable id is never assumed to match — a guess would merge events.
|
||||
expect(L.sameEvent('garbage', 'mlb:d:A@B')).toBe(false);
|
||||
expect(L.eventFingerprint('garbage')).toBeNull();
|
||||
});
|
||||
|
||||
test('KNOWN LIMIT: a non-prefix initialism is NOT reconciled, and fails closed', () => {
|
||||
// 'NYY' is not a prefix of 'newyorkyankees', so the rule cannot match them
|
||||
// and the two spellings become two logical Reads. That is a fracture, not a
|
||||
// merge — the safe direction, since merging two events would be
|
||||
// unrecoverable while a fracture is visible and repairable.
|
||||
//
|
||||
// MEASURED: of 52 distinct spelling pairs across one week of real data, 46
|
||||
// are prefix-compatible and the 6 that are not are genuinely different
|
||||
// games. No initialism-style pair for one game occurred. Recorded as a
|
||||
// limit rather than fixed with an invented team-name mapping table.
|
||||
expect(L.sameEvent('mlb:d:BostonRedSox@NewYorkYankees', 'mlb:d:BOS@NYY')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RETRY / IDEMPOTENCY', () => {
|
||||
test('retrying the same publication does not create a duplicate revision', () => {
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const retry = L.resolveLineage({ candidate: { ...CLAIM }, existing: [row1] });
|
||||
expect(retry.action).toBe(L.LINEAGE_ACTION.RECAPTURE);
|
||||
expect(retry.revision_ordinal).toBe(0);
|
||||
expect(retry.supersedes_id).toBeNull();
|
||||
expect(retry.recaptures_id).toBe(101);
|
||||
});
|
||||
|
||||
test('a later cycle observing the SAME claim is a recapture, not a revision', () => {
|
||||
// On the measured date only 183 of 5,376 props changed while 4,286 had
|
||||
// multiple rows. Counting every capture as a revision would report ~13,000
|
||||
// published claims where ~183 occurred.
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const later = { ...CLAIM, captured_at: '2026-08-27T22:00:00Z' };
|
||||
expect(L.resolveLineage({ candidate: later, existing: [row1] }).action)
|
||||
.toBe(L.LINEAGE_ACTION.RECAPTURE);
|
||||
});
|
||||
|
||||
test('the digest ignores non-claim fields and reacts to every claim field', () => {
|
||||
const base = L.claimDigest(CLAIM);
|
||||
// captured_at is not part of the claim.
|
||||
expect(L.claimDigest({ ...CLAIM, captured_at: 'zzz' })).toBe(base);
|
||||
// 1.5 and "1.5" are one claim, not two.
|
||||
expect(L.claimDigest({ ...CLAIM, line: 0.5 })).toBe(L.claimDigest({ ...CLAIM, line: 0.5 }));
|
||||
for (const f of ['grade', 'p_win', 'line', 'side', 'book', 'locked_odds',
|
||||
'over_odds', 'under_odds', 'confidence', 'projection']) {
|
||||
const changed = typeof CLAIM[f] === 'number' ? CLAIM[f] + 1 : `${CLAIM[f] || ''}X`;
|
||||
expect(L.claimDigest({ ...CLAIM, [f]: changed })).not.toBe(base);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONCURRENCY', () => {
|
||||
test('two writers that cannot see each other both target the SAME parent', () => {
|
||||
// This is the race, stated exactly. Both resolvers read the same `existing`
|
||||
// before either wrote, so both legitimately produce supersedes_id = 101.
|
||||
// No in-memory check can catch this — neither writer can see the other.
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const stale = [row1];
|
||||
|
||||
const a = L.resolveLineage({ candidate: { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' }, existing: stale });
|
||||
const b = L.resolveLineage({ candidate: { ...CLAIM, grade: 'D', p_win: 0.49, captured_at: '2026-08-27T19:00:01Z' }, existing: stale });
|
||||
|
||||
expect(a.supersedes_id).toBe(101);
|
||||
expect(b.supersedes_id).toBe(101);
|
||||
expect(a.revision_ordinal).toBe(b.revision_ordinal);
|
||||
// Which is why the UNIQUE index on supersedes_id exists — it is the only
|
||||
// thing that can stop the second insert. Proven against a real database in
|
||||
// the migration verification, not here.
|
||||
const mig = fs.readFileSync(path.join(ROOT, 'supabase/migrations/045_read_lineage.sql'), 'utf8');
|
||||
expect(mig).toMatch(/CREATE UNIQUE INDEX[\s\S]*?supersedes_id/);
|
||||
});
|
||||
|
||||
test('a resolver that CAN see the sibling appends after it — that is not a fork', () => {
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const a = { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' };
|
||||
const ra = L.resolveLineage({ candidate: a, existing: [row1] });
|
||||
const rowA = persisted(102, a, ra);
|
||||
const rb = L.resolveLineage({ candidate: { ...CLAIM, grade: 'D', p_win: 0.49, captured_at: '2026-08-27T20:00:00Z' }, existing: [row1, rowA] });
|
||||
expect(rb.ok).toBe(true);
|
||||
expect(rb.supersedes_id).toBe(102);
|
||||
expect(rb.revision_ordinal).toBe(2);
|
||||
});
|
||||
|
||||
test('a chronology containing a fork is reported as broken', () => {
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const forkA = { ...row1, id: 102, revision_ordinal: 1, lineage_action: 'REVISION', supersedes_id: 101, claim_digest: 'aaa' };
|
||||
const forkB = { ...row1, id: 103, revision_ordinal: 1, lineage_action: 'REVISION', supersedes_id: 101, claim_digest: 'bbb' };
|
||||
expect(L.chronology([row1, forkA, forkB]).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EXACT TIMESTAMP', () => {
|
||||
test('a row without a capture clock refuses rather than being stamped with now()', () => {
|
||||
const r = L.resolveLineage({ candidate: { ...CLAIM, captured_at: null }, existing: [] });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.refused).toBe('no_capture_clock');
|
||||
});
|
||||
|
||||
test('the standing revision at an instant is answerable', () => {
|
||||
const r1 = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, CLAIM, r1);
|
||||
const c2 = { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' };
|
||||
const r2 = L.resolveLineage({ candidate: c2, existing: [row1] });
|
||||
const row2 = persisted(102, c2, r2);
|
||||
|
||||
expect(L.revisionAt([row1, row2], '2026-08-27T15:00:00Z').grade || 'B').toBe('B');
|
||||
expect(L.revisionAt([row1, row2], '2026-08-27T15:00:00Z').id).toBe(101);
|
||||
expect(L.revisionAt([row1, row2], '2026-08-27T20:00:00Z').id).toBe(102);
|
||||
expect(L.revisionAt([row1, row2], '2026-08-27T10:00:00Z')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LEGACY UNKNOWN', () => {
|
||||
test('rows with no chain position produce LEGACY_UNVERIFIED, never an invented order', () => {
|
||||
const legacy = {
|
||||
id: 55, read_id: null, read_natural_key: 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.ok).toBe(true);
|
||||
expect(r.lineage_state).toBe(L.LINEAGE_STATE.LEGACY_UNVERIFIED);
|
||||
expect(r.action).toBe(L.LINEAGE_ACTION.ORIGIN);
|
||||
expect(r.supersedes_id).toBeNull();
|
||||
});
|
||||
|
||||
test('no backfill exists — nothing in the tree writes lineage onto historical rows', () => {
|
||||
const files = ['src/services/read/readLineage.js', 'src/services/retentionService.js'];
|
||||
for (const f of files) {
|
||||
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
|
||||
expect(src).not.toMatch(/backfill/i);
|
||||
}
|
||||
const mig = fs.readFileSync(path.join(ROOT, 'supabase/migrations/045_read_lineage.sql'), 'utf8');
|
||||
expect(mig).not.toMatch(/UPDATE\s+\w+\s+SET/i);
|
||||
expect(mig).not.toMatch(/\bDROP\b/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DUAL-WRITE', () => {
|
||||
const rows = () => ([
|
||||
{ ...CLAIM },
|
||||
{ ...CLAIM, side: 'under', grade: 'C', p_win: 0.39 },
|
||||
]);
|
||||
|
||||
test('lineage keys are declared on EVERY row even when unresolved', async () => {
|
||||
const r = rows();
|
||||
await retention.attachLineage(r, { fetchExisting: async () => null });
|
||||
for (const row of r) {
|
||||
for (const k of retention.LINEAGE_KEYS) expect(k in row).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('a lineage failure never throws and leaves rows persistable', async () => {
|
||||
const r = rows();
|
||||
const out = await retention.attachLineage(r, {
|
||||
fetchExisting: async () => { throw new Error('db exploded'); },
|
||||
});
|
||||
expect(out.error).toBe('db exploded');
|
||||
for (const row of r) expect(row.read_id).toBeNull();
|
||||
});
|
||||
|
||||
test('two rows of the SAME Read in one batch do not each mint an id', async () => {
|
||||
// over and under are different Reads; two captures of one side are not.
|
||||
const r = [{ ...CLAIM }, { ...CLAIM, grade: 'C', p_win: 0.55, captured_at: '2026-08-27T19:00:00Z' }];
|
||||
let n = 0;
|
||||
const out = await retention.attachLineage(r, {
|
||||
fetchExisting: async () => [],
|
||||
mintReadId: () => `R${++n}`,
|
||||
});
|
||||
expect(n).toBe(1);
|
||||
expect(r[0].read_id).toBe('R1');
|
||||
expect(r[1].read_id).toBe('R1');
|
||||
expect(out.origins).toBe(1);
|
||||
expect(out.revisions).toBe(1);
|
||||
});
|
||||
|
||||
test('counts distinguish origins, revisions and recaptures', async () => {
|
||||
const r = [{ ...CLAIM }, { ...CLAIM, captured_at: '2026-08-27T19:00:00Z' }];
|
||||
const out = await retention.attachLineage(r, { fetchExisting: async () => [], mintReadId: mint('R1') });
|
||||
expect(out.origins).toBe(1);
|
||||
expect(out.recaptures).toBe(1);
|
||||
expect(out.revisions).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SHADOW AUTHORITY', () => {
|
||||
test('no production read path imports the lineage module', () => {
|
||||
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'), // the dual-writer
|
||||
]);
|
||||
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 selects a lineage column', () => {
|
||||
const cols = ['read_natural_key', 'claim_digest', 'revision_ordinal', 'lineage_action', 'supersedes_id'];
|
||||
const roots = [path.join(ROOT, 'src/routes')];
|
||||
if (fs.existsSync(path.join(ROOT, 'web/src'))) roots.push(path.join(ROOT, 'web/src'));
|
||||
const walk = (dir, out = []) => {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(full, out); else out.push(full);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const hits = [];
|
||||
for (const r of roots) {
|
||||
for (const f of walk(r)) {
|
||||
if (!/\.(js|jsx|ts|tsx)$/.test(f)) continue;
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MODEL PRESERVATION', () => {
|
||||
test('lineage never touches a served field', () => {
|
||||
const { SERVED_FIELDS } = require('../../src/services/evaluator/gradeFreeze');
|
||||
for (const k of retention.LINEAGE_KEYS) expect(SERVED_FIELDS).not.toContain(k);
|
||||
});
|
||||
|
||||
test('attaching lineage leaves every claim field byte-identical', async () => {
|
||||
const row = { ...CLAIM };
|
||||
const before = JSON.stringify(row);
|
||||
await retention.attachLineage([row], { fetchExisting: async () => [], mintReadId: mint('R1') });
|
||||
const after = { ...row };
|
||||
for (const k of retention.LINEAGE_KEYS) delete after[k];
|
||||
expect(JSON.stringify(after)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('USER LOCK SEPARATION', () => {
|
||||
test('there is no user Lock-Read concept to separate from — recorded, not assumed', () => {
|
||||
// Traced: "locked" in this codebase means the SYSTEM's locked line/grade at
|
||||
// snapshot time (gradedAt.line / locked_odds), never a user action. No
|
||||
// lockRead / lock_read symbol exists anywhere in src or web/src.
|
||||
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 files = [
|
||||
...walk(path.join(ROOT, 'src')),
|
||||
...(fs.existsSync(path.join(ROOT, 'web/src')) ? walk(path.join(ROOT, 'web/src')) : []),
|
||||
];
|
||||
const hits = files.filter((f) => /lockRead|lock_read/i.test(fs.readFileSync(f, 'utf8')));
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MEASURED SHAPE (locked from a real read-only replay)', () => {
|
||||
test('the digest sees price in BOTH stores, which name it differently', () => {
|
||||
// `ledger_entries` carries locked_odds; `model_snapshots` carries
|
||||
// over_odds/under_odds. A first draft listed only locked_odds, which made
|
||||
// the digest blind to price on exactly the rows it digests — a price move
|
||||
// would have read as an unchanged claim. Caught by the replay failing on a
|
||||
// column that does not exist.
|
||||
const ms = { line: 0.5, side: 'over', book: 'dk', over_odds: 120, under_odds: -145, grade: 'B' };
|
||||
expect(L.claimDigest(ms)).not.toBe(L.claimDigest({ ...ms, over_odds: 180 }));
|
||||
const led = { line: 0.5, side: 'over', book: 'dk', locked_odds: 120, grade: 'B' };
|
||||
expect(L.claimDigest(led)).not.toBe(L.claimDigest({ ...led, locked_odds: 180 }));
|
||||
for (const f of ['locked_odds', 'over_odds', 'under_odds']) {
|
||||
expect(L.CLAIM_MARKET_FIELDS).toContain(f);
|
||||
}
|
||||
});
|
||||
|
||||
test('ONE natural key can legitimately hold TWO Reads', () => {
|
||||
// Measured: 18 of 30,746 identity groups carry genuinely different team
|
||||
// pairs. Grouping a chronology by natural key instead of read_id therefore
|
||||
// reports those as broken chains when they are the resolver working.
|
||||
const a = L.resolveLineage({ candidate: CLAIM, existing: [], mintReadId: mint('RA') });
|
||||
const rowA = persisted(101, CLAIM, a);
|
||||
const other = { ...CLAIM, game_id: 'mlb:2026-08-27:MinnesotaTwins@SanDiegoPadres' };
|
||||
const b = L.resolveLineage({ candidate: other, existing: [rowA], mintReadId: mint('RB') });
|
||||
const rowB = persisted(102, other, b);
|
||||
|
||||
expect(L.readNaturalKey(CLAIM)).toBe(L.readNaturalKey(other));
|
||||
expect(a.read_id).not.toBe(b.read_id);
|
||||
// Mixed bucket is rejected...
|
||||
expect(L.chronology([rowA, rowB]).ok).toBe(false);
|
||||
// ...and each Read on its own is a valid chain.
|
||||
expect(L.chronology([rowA]).ok).toBe(true);
|
||||
expect(L.chronology([rowB]).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('a claim that changes only PRICE is still a new published claim', () => {
|
||||
// 4,206 of 4,507 replayed revisions moved price without moving the grade.
|
||||
// The user was shown a different number, so it is a different claim.
|
||||
const r1 = L.resolveLineage({ candidate: { ...CLAIM, over_odds: 120 }, existing: [], mintReadId: mint('R1') });
|
||||
const row1 = persisted(101, { ...CLAIM, over_odds: 120 }, r1);
|
||||
const repriced = { ...CLAIM, over_odds: 180, captured_at: '2026-08-27T19:00:00Z' };
|
||||
const r2 = L.resolveLineage({ candidate: repriced, existing: [row1] });
|
||||
expect(r2.action).toBe(L.LINEAGE_ACTION.REVISION);
|
||||
expect(r2.revision_ordinal).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user