352016790a
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
493 lines
23 KiB
JavaScript
493 lines
23 KiB
JavaScript
'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);
|
|
});
|
|
});
|