Read integrity, as-of context, and the shadow matchup resolve (A1-A7)
Seven orders of measurement-first repair. The served grade does not move. A0/A1 — the unordered page walk returned the right COUNT and the wrong ROWS: 410-617 of 2,490 duplicated with an equal number never returned, while rows.length matched the server exactly. safePaginate orders on a real unique key, verifies the tuple at runtime, and THROWS on a query error instead of treating it as end-of-data. Both hits PROVES are withdrawn: they were drawn through that reader, and defense_by_direction's distinct-n was likely below the gate floor all along. A2/A2b — rolled across every reader: 11 FAIL -> 0. Composite keys pulled from pg_index (the context tables are dated-composite and had no single unique column). The unordered helper is deleted, not parked. A3 — ledgerService and retentionService defaulted the SAME env var to DIFFERENT versions, so no ledger row ever carried the marker eligibility requires. One source now. model_snapshots settlement moved onto the cron: 15,484 -> 28,894 settled, repaired-champion 0 -> 7,556. A4 — hitsFactorContext takes an as-of cutoff. Refusal over reconstruction: no row at-or-before the date means the factor does not apply, never the nearest row. Live path unchanged, proven 400/400 on real rows. A5 — factor_inputs freezes what the factor READ, never the multiplier, so an audit can recompute and check. It also recorded the finding: the three hits factors have NEVER fired. prop.opponent and prop.opposing_pitcher are read by the resolver and written by nothing. A6/A7 — matchupKeys resolves those keys from the posted lineup plus the schedule's probable pitchers, and fires the factors into a SHADOW freeze: 248 fires on 308 props, 245 of which would move the grade. The served forecast is untouched. specs/a8-shadow-factor-gate.md pre-registers the test that decides whether they ever go live. Nothing is turned on. CALIBRATION_DEPLOYED stays []. Both verdicts stay withdrawn. 4,772 tests / 371 suites green, web build exit 0, read-integrity harness 34/34. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* calibrationService.loadSettledRows — the Fix A1 regression.
|
||||
*
|
||||
* This reader measured 24.8% corrupt on production (617 of 2,490 rows returned
|
||||
* twice, an equal number never returned) via two independent defects: an
|
||||
* unordered page walk, and a query error swallowed as end-of-data. Both are
|
||||
* locked here against a fake `sb`, so the regression cannot return silently.
|
||||
*/
|
||||
|
||||
const calSvc = require('../../src/services/model/calibrationService');
|
||||
|
||||
/**
|
||||
* A fake PostgREST client for `ledger_entries`. Every filter in the reader's
|
||||
* chain is accepted and RECORDED, so a dropped filter fails the test rather than
|
||||
* quietly changing the row set.
|
||||
*/
|
||||
function fakeSb(rows, opts = {}) {
|
||||
const state = { rows, filters: [], selected: null, rangeCalls: 0, ordered: [] };
|
||||
const q = {
|
||||
select(cols) { state.selected = cols; return q; },
|
||||
eq(c, v) { state.filters.push(['eq', c, v]); return q; },
|
||||
is(c, v) { state.filters.push(['is', c, v]); return q; },
|
||||
in(c, v) { state.filters.push(['in', c, v]); return q; },
|
||||
not(c, o, v) { state.filters.push(['not', c, o, v]); return q; },
|
||||
lt(c, v) { state.filters.push(['lt', c, v]); return q; },
|
||||
order(c, o) { state.ordered.push([c, o && o.ascending !== false]); return q; },
|
||||
async range(from, to) {
|
||||
state.rangeCalls += 1;
|
||||
if (opts.errorOnRange && opts.errorOnRange(from)) {
|
||||
return { data: null, error: { message: 'connection reset by peer' } };
|
||||
}
|
||||
if (opts.beforeRange) opts.beforeRange(from, state);
|
||||
const key = state.ordered.length ? state.ordered[state.ordered.length - 1][0] : null;
|
||||
const out = key
|
||||
? [...state.rows].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0))
|
||||
: [...state.rows];
|
||||
return { data: out.slice(from, to + 1), error: null };
|
||||
},
|
||||
};
|
||||
return { state, from() { return q; } };
|
||||
}
|
||||
|
||||
const led = (id, p, won, date) => ({
|
||||
id, p_win: p, outcome: won ? 'hit' : 'miss', game_date: date, quarantine_reason: null,
|
||||
});
|
||||
const many = (n) => Array.from({ length: n }, (_, i) => led(i + 1, 0.5 + (i % 40) / 100, i % 2 === 0, '2026-08-01'));
|
||||
|
||||
describe('loadSettledRows — orders on a UNIQUE key, every page', () => {
|
||||
it('returns every row exactly once across a multi-page walk', async () => {
|
||||
const sb = fakeSb(many(2490));
|
||||
const rows = await calSvc.loadSettledRows(sb, { sport: 'mlb', stat: 'hits', before: '2026-08-09' });
|
||||
expect(rows).toHaveLength(2490);
|
||||
expect(new Set(rows.map((r) => r.id)).size).toBe(2490);
|
||||
expect(sb.state.rangeCalls).toBe(3);
|
||||
});
|
||||
|
||||
it('orders by id on EVERY page, not just the first', async () => {
|
||||
const sb = fakeSb(many(2490));
|
||||
await calSvc.loadSettledRows(sb, { before: '2026-08-09' });
|
||||
expect(sb.state.ordered).toHaveLength(sb.state.rangeCalls);
|
||||
for (const [col, asc] of sb.state.ordered) {
|
||||
expect(col).toBe('id'); // a UNIQUE key — an order on a non-unique
|
||||
expect(asc).toBe(true); // column still lets ties scramble
|
||||
}
|
||||
});
|
||||
|
||||
it('selects id — without it the ordering key is not in the payload', async () => {
|
||||
const sb = fakeSb(many(10));
|
||||
await calSvc.loadSettledRows(sb, { before: '2026-08-09' });
|
||||
expect(sb.state.selected).toMatch(/\bid\b/);
|
||||
});
|
||||
|
||||
it('keeps the point-in-time cut and every identity filter', async () => {
|
||||
const sb = fakeSb(many(10));
|
||||
await calSvc.loadSettledRows(sb, { sport: 'mlb', stat: 'hits', before: '2026-08-09' });
|
||||
expect(sb.state.filters).toEqual(expect.arrayContaining([
|
||||
['eq', 'sport', 'mlb'],
|
||||
['is', 'user_id', null],
|
||||
['eq', 'stat', 'hits'],
|
||||
['in', 'outcome', ['hit', 'miss']],
|
||||
['not', 'p_win', 'is', null],
|
||||
['lt', 'game_date', '2026-08-09'], // STRICTLY before — never the answer
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSettledRows — a failed read THROWS, never a short fit', () => {
|
||||
it('throws when a later page errors instead of returning the earlier pages', async () => {
|
||||
// The :117 defect returned 1,000 rows here and `build()` fitted a map on
|
||||
// them, indistinguishable from a complete fit.
|
||||
const sb = fakeSb(many(2490), { errorOnRange: (from) => from === 1000 });
|
||||
await expect(calSvc.loadSettledRows(sb, { before: '2026-08-09' }))
|
||||
.rejects.toThrow(/read failed at range 1000-1999/);
|
||||
});
|
||||
|
||||
it('propagates through fromLedger — a read failure is not "thin history"', async () => {
|
||||
// null means "not enough settled rows to fit"; a throw means "the read
|
||||
// broke". Collapsing them is what let a fragment look like a fit.
|
||||
const sb = fakeSb(many(2490), { errorOnRange: (from) => from === 1000 });
|
||||
await expect(calSvc.fromLedger(sb, { sport: 'mlb', stat: 'hits', before: '2026-08-09' }))
|
||||
.rejects.toThrow(/read failed/);
|
||||
});
|
||||
|
||||
it('still returns null (not a throw) when there is genuinely no client', async () => {
|
||||
await expect(calSvc.fromLedger(null, {})).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSettledRows — concurrent append (the production condition)', () => {
|
||||
it('returns every pre-existing row exactly once while the cron appends mid-walk', async () => {
|
||||
// ledger_entries is written at 14/19/22/1/3 UTC. With a stable ascending
|
||||
// unique key, an appended row lands after the cursor: nothing already
|
||||
// returned can repeat, nothing pending can be skipped.
|
||||
const original = many(2500);
|
||||
let nextId = 90000;
|
||||
const sb = fakeSb([...original], {
|
||||
beforeRange: (from, state) => {
|
||||
if (from > 0) state.rows.push(led(nextId++, 0.6, true, '2026-08-08'));
|
||||
},
|
||||
});
|
||||
const rows = await calSvc.loadSettledRows(sb, { before: '2026-08-09' });
|
||||
const seen = rows.map((r) => r.id);
|
||||
|
||||
expect(new Set(seen).size).toBe(seen.length); // zero duplicates
|
||||
for (const o of original) expect(seen).toContain(o.id); // zero dropped
|
||||
});
|
||||
|
||||
it('THROWS rather than returning a corrupted set if the walk ever repeats a row', async () => {
|
||||
// Belt and braces: if the ordering guarantee were ever lost, the reader
|
||||
// refuses instead of handing a duplicated sample to the fit.
|
||||
const sb = fakeSb([led(1, 0.5, true, '2026-08-01'), led(1, 0.5, true, '2026-08-01')]);
|
||||
await expect(calSvc.loadSettledRows(sb, { before: '2026-08-09' }))
|
||||
.rejects.toThrow(/duplicate key \(id\)=1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fix A1 does not change what is SERVED', () => {
|
||||
it('the deploy set is still empty — no stat serves a calibrated number', () => {
|
||||
const snapshotService = require('../../src/services/snapshotService');
|
||||
expect(snapshotService.CALIBRATION_DEPLOYED).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* factorFreeze — Fix A5.
|
||||
*
|
||||
* Freezes the RAW inputs a factor read, so a re-audit reads evidence instead of
|
||||
* reconstructing context. The multiplier is deliberately NOT stored: a stored
|
||||
* multiplier can only be compared to itself.
|
||||
*/
|
||||
|
||||
const ff = require('../../src/services/model/factorFreeze');
|
||||
const hf = require('../../src/services/model/hitsFactors');
|
||||
|
||||
const SPRAY = {
|
||||
as_of_date: '2026-08-07', player_key: 'aaron judge',
|
||||
pull_gb: 0.30, straight_gb: 0.20, oppo_gb: 0.10,
|
||||
pull_air: 0.20, straight_air: 0.10, oppo_air: 0.10,
|
||||
};
|
||||
const OAA = {
|
||||
'3B': { oaa: 2 }, SS: { oaa: 1 }, LF: { oaa: 0 }, '1B': { oaa: 0 },
|
||||
'2B': { oaa: 1 }, RF: { oaa: -1 }, CF: { oaa: 0 },
|
||||
};
|
||||
const SPLITS = {
|
||||
vl: { pa: 120, atBats: 110, hits: 33 },
|
||||
vr: { pa: 300, atBats: 280, hits: 70 },
|
||||
};
|
||||
const FULL_CTX = {
|
||||
spray: SPRAY, positionOaa: OAA, bats: 'R', throws: 'L',
|
||||
pitcherHardHit: 0.42, platoonSplits: SPLITS, archetype: 'BOMBER', as_of: '2026-08-07',
|
||||
};
|
||||
const PROP = {
|
||||
player: 'Aaron Judge', stat_type: 'hits', team: 'NYY',
|
||||
opponent: 'Red Sox', opposing_pitcher: 'Chris Sale',
|
||||
game_id: 'mlb:2026-08-07:BOS@NYY', game_date: '2026-08-07',
|
||||
};
|
||||
|
||||
describe('INPUTS, NEVER OUTPUTS', () => {
|
||||
it('stores the raw inputs', () => {
|
||||
const f = ff.freeze(FULL_CTX, PROP);
|
||||
expect(f.spray.pull_gb).toBe(0.30);
|
||||
expect(f.position_oaa).toEqual(OAA);
|
||||
expect(f.bats).toBe('R');
|
||||
expect(f.throws).toBe('L');
|
||||
expect(f.pitcher_hard_hit).toBe(0.42);
|
||||
expect(f.platoon.vl).toEqual({ pa: 120, ab: 110, hits: 33 });
|
||||
});
|
||||
|
||||
it('does NOT store the multiplier — it must stay recomputable, not asserted', () => {
|
||||
const f = ff.freeze(FULL_CTX, PROP);
|
||||
const flat = JSON.stringify(f);
|
||||
expect(f.multiplier).toBeUndefined();
|
||||
expect(f.factors_fired).toBeUndefined();
|
||||
expect(flat).not.toMatch(/multiplier/);
|
||||
});
|
||||
|
||||
it('carries the join keys AS THE RESOLVER SAW THEM', () => {
|
||||
const f = ff.freeze(FULL_CTX, PROP);
|
||||
expect(f.join).toEqual({ opponent: 'Red Sox', opposing_pitcher: 'Chris Sale' });
|
||||
});
|
||||
|
||||
it('records the join keys as NULL when the resolver had none — the finding, not an omission', () => {
|
||||
// This is the live case: nothing sets prop.opponent / prop.opposing_pitcher.
|
||||
const f = ff.freeze({ spray: SPRAY, positionOaa: null, bats: 'R', throws: null, pitcherHardHit: null, platoonSplits: SPLITS }, { player: 'x', team: 'NYY', game_id: 'g1' });
|
||||
expect(f.join).toEqual({ opponent: null, opposing_pitcher: null });
|
||||
expect(f.position_oaa).toBeNull();
|
||||
expect(f.throws).toBeNull();
|
||||
expect(f.pitcher_hard_hit).toBeNull();
|
||||
// and what COULD have been joined is recorded separately, never fed to a factor
|
||||
expect(f.available).toEqual({ team: 'NYY', game_id: 'g1', game_date: null });
|
||||
});
|
||||
|
||||
it('is versioned, so a shape change is distinguishable from a null', () => {
|
||||
expect(ff.freeze(FULL_CTX, PROP).v).toBe(ff.FREEZE_VERSION);
|
||||
});
|
||||
|
||||
it('returns null for no context rather than an empty husk', () => {
|
||||
expect(ff.freeze(null, PROP)).toBeNull();
|
||||
});
|
||||
|
||||
it('carries the archetype through (A4), never invents it', () => {
|
||||
expect(ff.freeze(FULL_CTX, PROP).archetype).toBe('BOMBER');
|
||||
expect(ff.freeze({ ...FULL_CTX, archetype: null }, PROP).archetype).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RECOMPUTE — the reason inputs beat an output', () => {
|
||||
it('a frozen vector reproduces the live multiplier EXACTLY', () => {
|
||||
const live = hf.hitsFactorMultiplier(FULL_CTX);
|
||||
const fromFrozen = ff.recompute(ff.freeze(FULL_CTX, PROP));
|
||||
expect(fromFrozen.multiplier).toBe(live.multiplier);
|
||||
expect(fromFrozen.factors_fired).toBe(live.factors_fired);
|
||||
expect(fromFrozen.applied.map((a) => a.factor).sort())
|
||||
.toEqual(live.applied.map((a) => a.factor).sort());
|
||||
});
|
||||
|
||||
it('reproduces the live multiplier on the DEGRADED live shape too', () => {
|
||||
const degraded = { spray: SPRAY, positionOaa: null, bats: 'R', throws: null, pitcherHardHit: null, platoonSplits: SPLITS };
|
||||
const live = hf.hitsFactorMultiplier(degraded);
|
||||
const fromFrozen = ff.recompute(ff.freeze(degraded, {}));
|
||||
expect(fromFrozen.multiplier).toBe(live.multiplier);
|
||||
expect(live.factors_fired).toBe(0); // the measured live state
|
||||
expect(fromFrozen.factors_fired).toBe(0);
|
||||
});
|
||||
|
||||
it('a frozen vector that fires would be CAUGHT if the factor code changed', () => {
|
||||
// The audit property: recompute uses the CURRENT factor code against the
|
||||
// FROZEN inputs, so a code change shows up as a difference.
|
||||
const frozen = ff.freeze(FULL_CTX, PROP);
|
||||
const fake = { hitsFactorMultiplier: () => ({ multiplier: 1.99, applied: [], skipped: [], factors_fired: 0 }) };
|
||||
expect(ff.recompute(frozen, { hitsFactors: fake }).multiplier).toBe(1.99);
|
||||
expect(ff.recompute(frozen).multiplier).not.toBe(1.99);
|
||||
});
|
||||
|
||||
it('recompute(null) is null', () => {
|
||||
expect(ff.recompute(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RECORDING IS NOT COMPUTING — the served grade cannot move', () => {
|
||||
it('freezing does not mutate the context the factor uses', () => {
|
||||
const ctx = JSON.parse(JSON.stringify(FULL_CTX));
|
||||
const before = JSON.stringify(ctx);
|
||||
ff.freeze(ctx, PROP);
|
||||
expect(JSON.stringify(ctx)).toBe(before);
|
||||
});
|
||||
|
||||
it('the grade path writes factor_inputs but leaves pOver alone', () => {
|
||||
const src = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', '..', 'src/services/intelligence/analyzeViaEngine1.js'), 'utf8');
|
||||
// Window from the A5 marker to the end of the factor block. A fixed byte
|
||||
// count was brittle — A6 inserted the shadow resolve inside it and pushed
|
||||
// the assignment past the window, failing a test whose subject had not moved.
|
||||
const start = src.indexOf('FIX A5 — FREEZE THE INPUTS');
|
||||
const block = src.slice(start, src.indexOf('const pWin = dir ===', start));
|
||||
expect(block).toMatch(/legacy\.factor_inputs = /);
|
||||
// The recording path must never assign the served probability.
|
||||
expect(block).not.toMatch(/pOver\s*=[^=]/);
|
||||
});
|
||||
|
||||
it('retention persists it as inputs, not as a multiplier', () => {
|
||||
const src = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', '..', 'src/services/retentionService.js'), 'utf8');
|
||||
expect(src).toMatch(/factor_inputs: s\.factor_inputs \|\| null/);
|
||||
expect(src).not.toMatch(/factor_multiplier:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retentionService carries the frozen vector onto the row', () => {
|
||||
const ret = require('../../src/services/retentionService');
|
||||
const ctx = { snapshotId: 's1', capturedAt: '2026-08-10T14:00:00Z', sport: 'mlb', gameDate: '2026-08-10' };
|
||||
|
||||
it('a graded hits side lands with its factor_inputs', () => {
|
||||
const frozen = ff.freeze(FULL_CTX, PROP);
|
||||
const rows = ret.__testing
|
||||
? []
|
||||
: ret.rowsFromSides({ player: 'Aaron Judge', stat_type: 'hits', line: 0.5, game_time: '2026-08-10T23:00:00Z' },
|
||||
[{ direction: 'over', grade: 'B', p_win: 0.55, factor_inputs: frozen }], ctx);
|
||||
expect(rows[0].factor_inputs).toEqual(frozen);
|
||||
expect(rows[0].factor_inputs.multiplier).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is null when the grader had no factor context', () => {
|
||||
const rows = ret.rowsFromSides({ player: 'A', stat_type: 'rbi', line: 0.5, game_time: '2026-08-10T23:00:00Z' },
|
||||
[{ direction: 'over', grade: 'C', p_win: 0.5 }], ctx);
|
||||
expect(rows[0].factor_inputs).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* hitsFactorContext as-of — Fix A4.
|
||||
*
|
||||
* The contamination this locks out: without a cutoff, `latestBy` returns TODAY'S
|
||||
* dated row, so auditing a 2026-08-07 grade would join a profile built from
|
||||
* games played after it. A clean read of the wrong date is still contamination.
|
||||
*
|
||||
* Fake client throughout; nothing here touches a network.
|
||||
*/
|
||||
|
||||
const ctxSvc = require('../../src/services/model/hitsFactorContext');
|
||||
const hf = require('../../src/services/model/hitsFactors');
|
||||
const { nameKey } = require('../../src/utils/playerName');
|
||||
|
||||
// Derived, never hardcoded: nameKey resolves nicknames ('Chris' -> 'christopher'),
|
||||
// so a literal key would silently miss and look like an honest absence.
|
||||
const BATTER = nameKey('Aaron Judge');
|
||||
const PITCHER = nameKey('Chris Sale');
|
||||
|
||||
/** Records every filter so an unapplied as-of bound fails the test. */
|
||||
function fakeSb(tables) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
from(table) {
|
||||
const state = { table, lte: null, eq: {} };
|
||||
calls.push(state);
|
||||
const q = {
|
||||
select() { return q; },
|
||||
eq(c, v) { state.eq[c] = v; return q; },
|
||||
lte(c, v) { if (c === 'as_of_date') state.lte = v; return q; },
|
||||
order() { return q; },
|
||||
async range(from, to) {
|
||||
let rows = tables[table] || [];
|
||||
if (state.lte) rows = rows.filter((r) => String(r.as_of_date) <= state.lte);
|
||||
return { data: rows.slice(from, to + 1), error: null };
|
||||
},
|
||||
};
|
||||
return q;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const spray = (d, key, id) => ({
|
||||
as_of_date: d, sport: 'mlb', season: 2026, source_id: id, player_key: key,
|
||||
pull_gb: 0.3, straight_gb: 0.2, oppo_gb: 0.1, pull_air: 0.2, straight_air: 0.1, oppo_air: 0.1,
|
||||
});
|
||||
// positionOaa is { position: { oaa, fielders } } — not a bare number.
|
||||
const defense = (d, team) => ({
|
||||
as_of_date: d, sport: 'mlb', season: 2026, team,
|
||||
position_oaa: {
|
||||
'3B': { oaa: 2 }, SS: { oaa: 1 }, LF: { oaa: 0 }, '1B': { oaa: 0 },
|
||||
'2B': { oaa: 1 }, RF: { oaa: -1 }, CF: { oaa: 0 },
|
||||
},
|
||||
});
|
||||
const platoon = (d, key) => ({
|
||||
as_of_date: d, sport: 'mlb', season: 2026, player_key: key,
|
||||
vl_pa: 120, vl_ab: 110, vl_hits: 33, vr_pa: 300, vr_ab: 280, vr_hits: 70,
|
||||
});
|
||||
const sc = (d, key, id, role, extra = {}) => ({
|
||||
as_of_date: d, sport: 'mlb', season: 2026, source_id: id, role, player_key: key,
|
||||
bats: 'R', throws: 'L', hard_hit_pct: 40, ...extra,
|
||||
});
|
||||
|
||||
const TABLES = {
|
||||
batter_spray: [spray('2026-08-05', BATTER, 1), spray('2026-08-09', BATTER, 1)],
|
||||
team_defense: [defense('2026-08-05', 'Red Sox'), defense('2026-08-09', 'Red Sox')],
|
||||
platoon_splits: [platoon('2026-08-05', BATTER), platoon('2026-08-09', BATTER)],
|
||||
statcast_aggregates: [sc(null, BATTER, 1, 'batter'), sc(null, PITCHER, 2, 'pitcher', { hard_hit_pct: 30 })],
|
||||
statcast_history: [
|
||||
sc('2026-08-05', BATTER, 1, 'batter'), sc('2026-08-09', BATTER, 1, 'batter'),
|
||||
sc('2026-08-05', PITCHER, 2, 'pitcher', { hard_hit_pct: 30 }),
|
||||
sc('2026-08-09', PITCHER, 2, 'pitcher', { hard_hit_pct: 99 }), // today's value
|
||||
],
|
||||
};
|
||||
|
||||
const PROP = { player: 'Aaron Judge', opponent: 'Red Sox', opposing_pitcher: 'Chris Sale' };
|
||||
|
||||
describe('as-of bound is APPLIED, and only when asked', () => {
|
||||
it('LIVE (no asOf) applies no as_of filter and reads statcast_aggregates', async () => {
|
||||
const sb = fakeSb(TABLES);
|
||||
const r = await ctxSvc.build(sb);
|
||||
expect(r.asOf).toBeNull();
|
||||
expect(r.__stats.mode).toBe('live');
|
||||
expect(r.__stats.statcast_source).toBe('statcast_aggregates');
|
||||
expect(sb.calls.every((c) => c.lte === null)).toBe(true);
|
||||
expect(sb.calls.map((c) => c.table)).toContain('statcast_aggregates');
|
||||
expect(sb.calls.map((c) => c.table)).not.toContain('statcast_history');
|
||||
});
|
||||
|
||||
it('AS-OF applies lte on EVERY dated table and reads statcast_history', async () => {
|
||||
const sb = fakeSb(TABLES);
|
||||
const r = await ctxSvc.build(sb, { asOf: '2026-08-07' });
|
||||
expect(r.__stats.mode).toBe('as-of (audit)');
|
||||
expect(r.__stats.statcast_source).toBe('statcast_history');
|
||||
for (const t of ['batter_spray', 'team_defense', 'platoon_splits', 'statcast_history']) {
|
||||
const c = sb.calls.find((x) => x.table === t);
|
||||
expect(c.lte).toBe('2026-08-07');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('THE CONTAMINATION, LOCKED OUT', () => {
|
||||
it('never returns a row dated after asOf', async () => {
|
||||
const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' });
|
||||
const c = r(PROP);
|
||||
expect(c.spray.as_of_date).toBe('2026-08-05'); // NOT the 08-09 row
|
||||
expect(c.as_of).toBe('2026-08-07');
|
||||
});
|
||||
|
||||
it("picks up TODAY's value on the live path — proving the test can tell them apart", async () => {
|
||||
const live = await ctxSvc.build(fakeSb(TABLES));
|
||||
const past = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' });
|
||||
// The pitcher's hard-hit rate moved 30 -> 99 on 08-09.
|
||||
expect(live(PROP).pitcherHardHit).toBeCloseTo(0.30, 5);
|
||||
expect(past(PROP).pitcherHardHit).toBeCloseTo(0.30, 5);
|
||||
const today = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-09' });
|
||||
expect(today(PROP).pitcherHardHit).toBeCloseTo(0.99, 5);
|
||||
});
|
||||
|
||||
it('takes the LATEST row within the bound, not the earliest', async () => {
|
||||
const tables = { ...TABLES, batter_spray: [spray('2026-08-01', BATTER, 1), spray('2026-08-05', BATTER, 1), spray('2026-08-09', BATTER, 1)] };
|
||||
const r = await ctxSvc.build(fakeSb(tables), { asOf: '2026-08-07' });
|
||||
expect(r(PROP).spray.as_of_date).toBe('2026-08-05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('REFUSAL OVER RECONSTRUCTION', () => {
|
||||
it('an entity with NO row at-or-before asOf yields null — never the nearest row', async () => {
|
||||
// Every dated row is AFTER the cutoff. The honest answer is "no context".
|
||||
const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-07-01' });
|
||||
expect(r).toBeNull(); // nothing loaded at all → serve unadjusted
|
||||
});
|
||||
|
||||
it('refuses ONE entity while still serving another', async () => {
|
||||
const tables = {
|
||||
...TABLES,
|
||||
batter_spray: [spray('2026-08-09', BATTER, 1)], // only AFTER the cutoff
|
||||
platoon_splits: [platoon('2026-08-05', BATTER)], // present at the cutoff
|
||||
};
|
||||
const r = await ctxSvc.build(fakeSb(tables), { asOf: '2026-08-07' });
|
||||
const c = r(PROP);
|
||||
expect(c.spray).toBeNull(); // refused
|
||||
expect(c.platoonSplits).not.toBeNull();
|
||||
});
|
||||
|
||||
it('a refused input leaves the BASE RATE UNTOUCHED (factor does not apply)', () => {
|
||||
// The end-to-end consequence: no spray → no defense_by_direction multiplier.
|
||||
const withSpray = hf.hitsFactorMultiplier({
|
||||
spray: TABLES.batter_spray[0], positionOaa: TABLES.team_defense[0].position_oaa, bats: 'R',
|
||||
});
|
||||
const refused = hf.hitsFactorMultiplier({
|
||||
spray: null, positionOaa: TABLES.team_defense[0].position_oaa, bats: 'R',
|
||||
});
|
||||
expect(refused.applied.map((a) => a.factor)).not.toContain('defense_by_direction');
|
||||
expect(refused.skipped.map((s) => s.factor)).toContain('defense_by_direction');
|
||||
// and with nothing else readable the multiplier is exactly 1 — a no-op.
|
||||
expect(refused.multiplier).toBe(1);
|
||||
expect(withSpray.factors_fired).toBeGreaterThan(refused.factors_fired);
|
||||
});
|
||||
|
||||
it('adjustProbability is unchanged when every input is refused', () => {
|
||||
const out = hf.adjustProbability(0.42, { spray: null, positionOaa: null, bats: null });
|
||||
expect(out.p_adjusted).toBe(0.42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archetype is CARRIED, never invented', () => {
|
||||
it('passes the graded row archetype through', async () => {
|
||||
const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' });
|
||||
expect(r({ ...PROP, archetype: 'BOMBER' }).archetype).toBe('BOMBER');
|
||||
});
|
||||
|
||||
it('is null when the caller supplies none — no context table holds it', async () => {
|
||||
const r = await ctxSvc.build(fakeSb(TABLES), { asOf: '2026-08-07' });
|
||||
expect(r(PROP).archetype).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the context read stays on safePaginate with the real composite key', () => {
|
||||
it('orders by the full unique tuple of each table', () => {
|
||||
const src = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', '..', 'src/services/model/hitsFactorContext.js'), 'utf8');
|
||||
expect(src).toMatch(/uniqueKeyFor\(table\)/);
|
||||
expect(src).toMatch(/require\('\.\.\/\.\.\/utils\/safePaginate'\)/);
|
||||
// No hand-rolled walk left behind.
|
||||
expect(src).not.toMatch(/for \(let from = 0/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A6 — the join keys, resolved into a SHADOW resolve.
|
||||
*
|
||||
* A5 measured all three hits factors skipped on 596/596 because nothing sets
|
||||
* `prop.opponent` / `prop.opposing_pitcher`. A6 resolves them and freezes what
|
||||
* the factors WOULD do. The served grade must not move.
|
||||
*/
|
||||
|
||||
const mk = require('../../src/services/model/matchupKeys');
|
||||
const ctxSvc = require('../../src/services/model/hitsFactorContext');
|
||||
const hf = require('../../src/services/model/hitsFactors');
|
||||
const ff = require('../../src/services/model/factorFreeze');
|
||||
const { nameKey } = require('../../src/utils/playerName');
|
||||
|
||||
const JUDGE = nameKey('Aaron Judge');
|
||||
|
||||
const fakeSb = (rows) => ({
|
||||
from() {
|
||||
const st = { lte: null };
|
||||
const q = {
|
||||
select() { return q; }, eq() { return q; },
|
||||
lte(c, v) { if (c === 'as_of_date') st.lte = v; return q; },
|
||||
order() { return q; },
|
||||
async range(f, t2) {
|
||||
const r = st.lte ? rows.filter((x) => String(x.as_of_date) <= st.lte) : rows;
|
||||
return { data: r.slice(f, t2 + 1), error: null };
|
||||
},
|
||||
};
|
||||
return q;
|
||||
},
|
||||
});
|
||||
|
||||
const LINEUP = [{
|
||||
as_of_date: '2026-08-09', game_date: '2026-08-09', sport: 'mlb',
|
||||
game_pk: 1, team: 'New York Yankees', side: 'home', player_key: JUDGE,
|
||||
}];
|
||||
const SCHED = [{
|
||||
home: { team: 'New York Yankees', probablePitcher: { id: 1, name: 'Gerrit Cole' } },
|
||||
away: { team: 'Boston Red Sox', probablePitcher: { id: 2, name: 'Chris Sale' } },
|
||||
}];
|
||||
|
||||
describe('key resolution', () => {
|
||||
it('resolves opponent + opposing pitcher from lineup + schedule', async () => {
|
||||
const r = await mk.build({ sb: fakeSb(LINEUP), getSchedule: async () => SCHED, gameDate: '2026-08-09' });
|
||||
const k = r({ player: 'Aaron Judge' });
|
||||
expect(k.opponent).toBe('Boston Red Sox');
|
||||
expect(k.opposing_pitcher).toBe('Chris Sale');
|
||||
expect(k.source).toBe('lineup_context+schedule');
|
||||
expect(k.refused).toBeNull();
|
||||
});
|
||||
|
||||
it('REFUSES a player with no posted lineup row — never guesses from the prop teams', async () => {
|
||||
const r = await mk.build({ sb: fakeSb(LINEUP), getSchedule: async () => SCHED, gameDate: '2026-08-09' });
|
||||
// The prop knows both teams; the resolver still refuses, because which side
|
||||
// the hitter bats for is not knowable from that.
|
||||
const k = r({ player: 'Nobody Atall', home_team: 'NYY', away_team: 'BOS' });
|
||||
expect(k.opponent).toBeNull();
|
||||
expect(k.opposing_pitcher).toBeNull();
|
||||
expect(k.refused).toBe('no_lineup_row');
|
||||
});
|
||||
|
||||
it('refuses the pitcher when the game has no probable, keeping the opponent', async () => {
|
||||
const noSP = [{ home: { team: 'New York Yankees' }, away: { team: 'Boston Red Sox' } }];
|
||||
const r = await mk.build({ sb: fakeSb(LINEUP), getSchedule: async () => noSP, gameDate: '2026-08-09' });
|
||||
const k = r({ player: 'Aaron Judge' });
|
||||
expect(k.opponent).toBe('Boston Red Sox');
|
||||
expect(k.opposing_pitcher).toBeNull();
|
||||
expect(k.refused).toBe('no_probable_pitcher');
|
||||
});
|
||||
|
||||
it('bounds the lineup read by as-of (A4 discipline)', async () => {
|
||||
const future = [{ ...LINEUP[0], as_of_date: '2026-08-20' }];
|
||||
const r = await mk.build({ sb: fakeSb(future), getSchedule: async () => SCHED, gameDate: '2026-08-09', asOf: '2026-08-09' });
|
||||
expect(r({ player: 'Aaron Judge' }).refused).toBe('no_lineup_row');
|
||||
});
|
||||
|
||||
it('returns null (not a stub) when nothing loads', async () => {
|
||||
expect(await mk.build({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the resolver: LIVE unchanged, SHADOW fires', () => {
|
||||
const TABLES = {
|
||||
batter_spray: [{ as_of_date: '2026-08-09', sport: 'mlb', season: 2026, source_id: 1, player_key: JUDGE, pull_gb: 0.3, straight_gb: 0.2, oppo_gb: 0.1, pull_air: 0.2, straight_air: 0.1, oppo_air: 0.1 }],
|
||||
team_defense: [{ as_of_date: '2026-08-09', sport: 'mlb', season: 2026, team: 'Boston Red Sox', position_oaa: { '3B': { oaa: 2 }, SS: { oaa: 1 }, LF: { oaa: 0 }, '1B': { oaa: 0 }, '2B': { oaa: 1 }, RF: { oaa: -1 }, CF: { oaa: 0 } } }],
|
||||
platoon_splits: [{ as_of_date: '2026-08-09', sport: 'mlb', season: 2026, player_key: JUDGE, vl_pa: 120, vl_ab: 110, vl_hits: 33, vr_pa: 300, vr_ab: 280, vr_hits: 70 }],
|
||||
statcast_aggregates: [
|
||||
{ sport: 'mlb', season: 2026, source_id: 1, role: 'batter', player_key: JUDGE, bats: 'R', hard_hit_pct: 40 },
|
||||
{ sport: 'mlb', season: 2026, source_id: 2, role: 'pitcher', player_key: nameKey('Chris Sale'), throws: 'L', hard_hit_pct: 45 },
|
||||
],
|
||||
};
|
||||
const sbCtx = () => ({
|
||||
from(table) {
|
||||
const q = { select() { return q; }, eq() { return q; }, lte() { return q; }, order() { return q; },
|
||||
async range(f, t2) { return { data: (TABLES[table] || []).slice(f, t2 + 1), error: null }; } };
|
||||
return q;
|
||||
},
|
||||
});
|
||||
const PROP = { player: 'Aaron Judge', stat_type: 'hits' };
|
||||
const KEYS = { opponent: 'Boston Red Sox', opposing_pitcher: 'Chris Sale', source: 'lineup_context+schedule', refused: null };
|
||||
|
||||
it('WITHOUT keys nothing fires — the measured live state', async () => {
|
||||
const ctx = await ctxSvc.build(sbCtx());
|
||||
const c = ctx(PROP, 'mlb');
|
||||
const m = hf.hitsFactorMultiplier(c);
|
||||
expect(c.positionOaa).toBeNull();
|
||||
expect(c.throws).toBeNull();
|
||||
expect(m.factors_fired).toBe(0);
|
||||
expect(m.multiplier).toBe(1);
|
||||
});
|
||||
|
||||
it('WITH keys the factors fire — same indexes, different join', async () => {
|
||||
const ctx = await ctxSvc.build(sbCtx());
|
||||
const c = ctx(PROP, 'mlb', KEYS);
|
||||
const m = hf.hitsFactorMultiplier(c);
|
||||
expect(c.positionOaa).not.toBeNull();
|
||||
expect(c.throws).toBe('L');
|
||||
expect(m.factors_fired).toBeGreaterThan(0);
|
||||
expect(m.multiplier).not.toBe(1);
|
||||
});
|
||||
|
||||
it('the shadow block records the would-fire result and stays recomputable', async () => {
|
||||
const ctx = await ctxSvc.build(sbCtx());
|
||||
const live = ctx(PROP, 'mlb');
|
||||
const shadowCtx = ctx(PROP, 'mlb', KEYS);
|
||||
const shM = hf.hitsFactorMultiplier(shadowCtx);
|
||||
const frozen = ff.freeze(live, PROP, ff.shadowBlock(shadowCtx, shM, KEYS));
|
||||
|
||||
expect(frozen.would_fire.multiplier).toBe(shM.multiplier);
|
||||
expect(frozen.would_fire.factors_fired).toBe(shM.factors_fired);
|
||||
expect(frozen.would_fire.keys.opponent).toBe('Boston Red Sox');
|
||||
// Recomputable from what was stored beside it.
|
||||
const re = hf.hitsFactorMultiplier({
|
||||
spray: live.spray, bats: live.bats, platoonSplits: live.platoonSplits,
|
||||
positionOaa: frozen.would_fire.shadow_inputs.position_oaa,
|
||||
throws: frozen.would_fire.shadow_inputs.throws,
|
||||
pitcherHardHit: frozen.would_fire.shadow_inputs.pitcher_hard_hit,
|
||||
});
|
||||
expect(re.multiplier).toBe(frozen.would_fire.multiplier);
|
||||
});
|
||||
|
||||
it('the LIVE frozen inputs still show the refusal — shadow does not overwrite them', async () => {
|
||||
const ctx = await ctxSvc.build(sbCtx());
|
||||
const live = ctx(PROP, 'mlb');
|
||||
const shadowCtx = ctx(PROP, 'mlb', KEYS);
|
||||
const frozen = ff.freeze(live, PROP, ff.shadowBlock(shadowCtx, hf.hitsFactorMultiplier(shadowCtx), KEYS));
|
||||
expect(frozen.position_oaa).toBeNull(); // what the SERVED grade saw
|
||||
expect(frozen.throws).toBeNull();
|
||||
expect(frozen.would_fire.shadow_inputs.throws).toBe('L'); // what it WOULD have seen
|
||||
});
|
||||
|
||||
it('no shadow block when the keys were refused', async () => {
|
||||
const ctx = await ctxSvc.build(sbCtx());
|
||||
const live = ctx(PROP, 'mlb');
|
||||
expect(ff.freeze(live, PROP, null).would_fire).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the served grade cannot read the shadow', () => {
|
||||
it('the engine assigns pOver only from the LIVE adjust, never from would_fire', () => {
|
||||
const src = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', '..', 'src/services/intelligence/analyzeViaEngine1.js'), 'utf8');
|
||||
const i = src.indexOf('FIX A6 — SHADOW RESOLVE');
|
||||
const block = src.slice(i, i + 900);
|
||||
expect(block).toMatch(/shadow = ffz\.shadowBlock/);
|
||||
expect(block).not.toMatch(/pOver\s*=/);
|
||||
// and the only pOver assignment in the factor area is gated on the LIVE adj
|
||||
expect(src).toMatch(/if \(adj\.p_adjusted != null && adj\.factors_fired > 0\) \{\s*\n\s*pOver = adj\.p_adjusted;/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Read-integrity harness — spec: specs/read-integrity-harness.md §5.
|
||||
* Every case uses a FAKE page fetcher; nothing here touches a network.
|
||||
*/
|
||||
|
||||
const ri = require('../../src/utils/readIntegrity');
|
||||
|
||||
/** A fetcher that serves a fixed list of pages, then empties. */
|
||||
const pagesOf = (pages) => async (from, to) => {
|
||||
const size = to - from + 1;
|
||||
const idx = Math.floor(from / size);
|
||||
return pages[idx] || [];
|
||||
};
|
||||
|
||||
const row = (id) => ({ id });
|
||||
const ids = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => row(a + i));
|
||||
|
||||
describe('walk', () => {
|
||||
it('stops on a short page', async () => {
|
||||
const rows = await ri.walk(pagesOf([ids(1, 10), ids(11, 15)]), 10);
|
||||
expect(rows).toHaveLength(15);
|
||||
});
|
||||
|
||||
it('stops on an empty page after a full one', async () => {
|
||||
const rows = await ri.walk(pagesOf([ids(1, 10), []]), 10);
|
||||
expect(rows).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('keeps duplicates — collapsing them here would erase the measurement', async () => {
|
||||
const rows = await ri.walk(pagesOf([ids(1, 10), [row(3), row(4)]]), 10);
|
||||
expect(rows).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('PROPAGATES a fetch error instead of treating it as end-of-data', async () => {
|
||||
// The calibrationService.fromLedger:117 defect: `if (error || !data) break`
|
||||
// makes a partial read indistinguishable from a complete one.
|
||||
const boom = async () => { throw new Error('fetch failed'); };
|
||||
await expect(ri.walk(boom, 10)).rejects.toThrow('fetch failed');
|
||||
});
|
||||
|
||||
it('refuses to report a runaway walk as complete', async () => {
|
||||
const never = async () => ids(1, 10); // always a full page
|
||||
await expect(ri.walk(never, 10, 3)).rejects.toThrow(/maxPages/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyze — the verdict is measured, never inferred', () => {
|
||||
it('PASSes a walk that is set-identical to the control', () => {
|
||||
const r = ri.analyze({ exactCount: 15, unordered: ids(1, 15), ordered: ids(1, 15) });
|
||||
expect(r.verdict).toBe(ri.VERDICT.PASS);
|
||||
expect(r.unordered.duplicates).toBe(0);
|
||||
expect(r.corruption_pct).toBe(0);
|
||||
});
|
||||
|
||||
it('FAILs the measured production shape: last page re-emits earlier rows', () => {
|
||||
// 2,490-row read reproduced in miniature: the tail page repeats rows already
|
||||
// returned, so an equal number of real rows are never seen.
|
||||
const unordered = [...ids(1, 10), ...ids(11, 20), ...[row(3), row(4), row(5)]];
|
||||
const ordered = ids(1, 23);
|
||||
const r = ri.analyze({ exactCount: 23, unordered, ordered });
|
||||
expect(r.verdict).toBe(ri.VERDICT.FAIL);
|
||||
expect(r.unordered.rows).toBe(23); // the count looks perfect
|
||||
expect(r.unordered.distinct).toBe(20); // the rows are not
|
||||
expect(r.unordered.duplicates).toBe(3);
|
||||
expect(r.unordered.missing_vs_control).toBe(3);
|
||||
expect(r.corruption_pct).toBeCloseTo(13.0, 1);
|
||||
});
|
||||
|
||||
it('META-SCAR GUARD: an ORDERED walk that still duplicates is FAIL, not PASS', () => {
|
||||
// The clause is not the result. A harness that credited the presence of an
|
||||
// ORDER BY would be verifying its own intention.
|
||||
const orderedButBroken = [...ids(1, 20), row(20)];
|
||||
const r = ri.analyze({ exactCount: 21, unordered: orderedButBroken, ordered: ids(1, 21) });
|
||||
expect(r.verdict).toBe(ri.VERDICT.FAIL);
|
||||
expect(r.unordered.duplicates).toBe(1);
|
||||
});
|
||||
|
||||
it('reports CONTROL_INVALID when the control is short of the server count', () => {
|
||||
const r = ri.analyze({ exactCount: 30, unordered: ids(1, 25), ordered: ids(1, 25) });
|
||||
expect(r.verdict).toBe(ri.VERDICT.CONTROL_INVALID);
|
||||
expect(r.control_valid).toBe(false);
|
||||
});
|
||||
|
||||
it('never issues PASS without a server count to validate against', () => {
|
||||
const r = ri.analyze({ exactCount: null, unordered: ids(1, 5), ordered: ids(1, 5) });
|
||||
expect(r.verdict).not.toBe(ri.VERDICT.PASS);
|
||||
expect(r.verdict).toBe(ri.VERDICT.CONTROL_INVALID);
|
||||
});
|
||||
|
||||
it('reports KEY_NOT_UNIQUE rather than blaming the walk for duplicate data', () => {
|
||||
// batter_spray keyed on player_key|as_of_date has genuine duplicate keys.
|
||||
const dup = [{ k: 'a' }, { k: 'a' }, { k: 'b' }];
|
||||
const r = ri.analyze({ exactCount: 3, unordered: dup, ordered: dup, keyCols: ['k'] });
|
||||
expect(r.verdict).toBe(ri.VERDICT.KEY_NOT_UNIQUE);
|
||||
expect(r.key_unique).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a composite key across columns', () => {
|
||||
const rows = [{ a: 1, b: 'x' }, { a: 1, b: 'y' }];
|
||||
const r = ri.analyze({ exactCount: 2, unordered: rows, ordered: rows, keyCols: ['a', 'b'] });
|
||||
expect(r.verdict).toBe(ri.VERDICT.PASS);
|
||||
});
|
||||
|
||||
it('distinguishes an EXTRA row from a missing one', () => {
|
||||
const r = ri.analyze({ exactCount: 5, unordered: [...ids(1, 5), row(99)], ordered: ids(1, 5) });
|
||||
expect(r.unordered.extra_vs_control).toBe(1);
|
||||
expect(r.unordered.missing_vs_control).toBe(0);
|
||||
expect(r.verdict).toBe(ri.VERDICT.FAIL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyFilters', () => {
|
||||
const fake = () => {
|
||||
const calls = [];
|
||||
const q = {
|
||||
calls,
|
||||
eq: (...a) => { calls.push(['eq', ...a]); return q; },
|
||||
is: (...a) => { calls.push(['is', ...a]); return q; },
|
||||
in: (...a) => { calls.push(['in', ...a]); return q; },
|
||||
not: (...a) => { calls.push(['not', ...a]); return q; },
|
||||
lt: (...a) => { calls.push(['lt', ...a]); return q; },
|
||||
gte: (...a) => { calls.push(['gte', ...a]); return q; },
|
||||
};
|
||||
return q;
|
||||
};
|
||||
|
||||
it('chains ops in order', () => {
|
||||
const q = ri.applyFilters(fake(), [
|
||||
['eq', 'sport', 'mlb'],
|
||||
['is', 'user_id', null],
|
||||
['in', 'outcome', ['hit', 'miss']],
|
||||
['not', 'p_win', 'is', null],
|
||||
['lt', 'game_date', '2026-08-09'],
|
||||
]);
|
||||
expect(q.calls).toEqual([
|
||||
['eq', 'sport', 'mlb'],
|
||||
['is', 'user_id', null],
|
||||
['in', 'outcome', ['hit', 'miss']],
|
||||
['not', 'p_win', 'is', null],
|
||||
['lt', 'game_date', '2026-08-09'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('THROWS on an unknown op rather than skipping it — a dropped filter changes the row set', () => {
|
||||
expect(() => ri.applyFilters(fake(), [['contains', 'x', 'y']])).toThrow(/unsupported filter op/);
|
||||
});
|
||||
|
||||
it('handles an empty filter list', () => {
|
||||
expect(ri.applyFilters(fake(), []).calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('measure', () => {
|
||||
const spec = { id: 'r1', source: 'f.js:1', table: 't', key: ['id'] };
|
||||
|
||||
it('assembles a full result for a clean reader', async () => {
|
||||
const r = await ri.measure(spec, {
|
||||
pageSize: 10,
|
||||
exactCount: async () => 15,
|
||||
fetchUnordered: pagesOf([ids(1, 10), ids(11, 15)]),
|
||||
fetchOrdered: pagesOf([ids(1, 10), ids(11, 15)]),
|
||||
});
|
||||
expect(r.verdict).toBe(ri.VERDICT.PASS);
|
||||
expect(r.pages).toBe(2);
|
||||
expect(r.id).toBe('r1');
|
||||
});
|
||||
|
||||
it('reports ERROR (not PASS, not empty) when a page fetch throws', async () => {
|
||||
const r = await ri.measure(spec, {
|
||||
pageSize: 10,
|
||||
exactCount: async () => 15,
|
||||
fetchUnordered: async () => { throw new Error('boom'); },
|
||||
fetchOrdered: pagesOf([ids(1, 15)]),
|
||||
});
|
||||
expect(r.verdict).toBe(ri.VERDICT.ERROR);
|
||||
expect(r.reason).toMatch(/boom/);
|
||||
});
|
||||
|
||||
it('reports ERROR when the count itself fails', async () => {
|
||||
const r = await ri.measure(spec, {
|
||||
pageSize: 10,
|
||||
exactCount: async () => { throw new Error('count failed'); },
|
||||
fetchUnordered: pagesOf([ids(1, 5)]),
|
||||
fetchOrdered: pagesOf([ids(1, 5)]),
|
||||
});
|
||||
expect(r.verdict).toBe(ri.VERDICT.ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the harness writes nothing', () => {
|
||||
it('has no insert/update/upsert/delete anywhere in its source', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
for (const f of ['../../src/utils/readIntegrity.js', '../../scripts/read-integrity.js']) {
|
||||
const p = path.join(__dirname, f);
|
||||
if (!fs.existsSync(p)) continue;
|
||||
const src = fs.readFileSync(p, 'utf8');
|
||||
expect(src).not.toMatch(/\.insert\(|\.update\(|\.upsert\(|\.delete\(|\.rpc\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Fix A2 — the learning readers do not regress to the unordered walk.
|
||||
*
|
||||
* WHAT THIS TEST IS AND IS NOT. It does NOT assert that a reader is correct —
|
||||
* correctness is measured by `scripts/read-integrity.js` against an ordered
|
||||
* control on live data, and a source grep can never stand in for that (the
|
||||
* meta-scar rule, specs/read-integrity-harness.md §2).
|
||||
*
|
||||
* What it DOES assert is structural: the two tables measured corrupt
|
||||
* (`ledger_entries`, `model_snapshots`) are never read through the legacy
|
||||
* `page()` helper again, the scripts stay importable without executing, and the
|
||||
* named READS the harness measures still exist. Those are the properties that,
|
||||
* if they silently regressed, would make the next harness run measure the wrong
|
||||
* thing.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SCRIPTS = [
|
||||
'prove-hit-factors', 'prove-tb-factors', 'cluster-prove',
|
||||
'tb-solo-and-interactions', 'build-grade-bands', 'proven-status',
|
||||
'champion-ablation',
|
||||
// A2b — the context readers and the two write-scripts
|
||||
'prove-runs-rbi', 'pitcher-prove-k', 'prove-park-weather', 'skill-v1-stagea',
|
||||
'stagea-gate-run', 'backfill-context', 'reconstruct-game-environment',
|
||||
'calibrate-hits',
|
||||
];
|
||||
|
||||
/** Every table these scripts page through must have a recorded unique key. */
|
||||
const KEYED_TABLES = ['ledger_entries', 'model_snapshots', 'statcast_aggregates',
|
||||
'batter_spray', 'team_defense', 'platoon_splits', 'park_dimensions',
|
||||
'hitter_opportunity', 'lineup_context', 'game_context'];
|
||||
|
||||
const CORRUPTED_TABLES = ['ledger_entries', 'model_snapshots'];
|
||||
|
||||
/**
|
||||
* READS DELIBERATELY LEFT ON THE LEGACY WALK — named, with the reason.
|
||||
*
|
||||
* The A2 order scoped the fix to the eleven readers measured FAIL and said
|
||||
* explicitly to leave the siblings that measured 0%. Those siblings are listed
|
||||
* here rather than excluded by a loosened regex, so the gap is VISIBLE and
|
||||
* tracked instead of invisible.
|
||||
*
|
||||
* They are not safe by construction — they are clean by plan. The same query on
|
||||
* `ledger_entries` measured 16.5% one minute and 24.8% the next, so a 0% reading
|
||||
* is a fact about today, not a property. These should move in a follow-up.
|
||||
*/
|
||||
const KNOWN_LEGACY_READS = {
|
||||
// A2b closed the last exceptions: the three reads A2 left on the legacy walk
|
||||
// are converted, and the unordered helper is DELETED from every script rather
|
||||
// than parked beside the safe one — a dead broken helper is an invitation.
|
||||
};
|
||||
|
||||
const read = (name) => fs.readFileSync(path.join(__dirname, '..', '..', 'scripts', `${name}.js`), 'utf8');
|
||||
|
||||
describe.each(SCRIPTS)('scripts/%s.js', (name) => {
|
||||
const src = read(name);
|
||||
const allowed = KNOWN_LEGACY_READS[name] || [];
|
||||
|
||||
it('never reads a keyed table through an unordered walk', () => {
|
||||
for (const table of KEYED_TABLES) {
|
||||
// `page(sb, 'ledger_entries'` — the unordered walk. `pageSafe(...)` is fine.
|
||||
expect(src).not.toMatch(new RegExp(`[^e]page\\(\\s*sb,\\s*'${table}'`));
|
||||
}
|
||||
expect(allowed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('carries NO unordered page() helper at all — the landmine is deleted', () => {
|
||||
const code = src.replace(/\/\/.*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const defs = code.match(/async function page\(/g) || [];
|
||||
for (const _ of defs) {
|
||||
// A surviving helper must itself order; an unordered one must not exist.
|
||||
expect(code).toMatch(/paginate\(/);
|
||||
}
|
||||
});
|
||||
|
||||
it('looks its key up from the schema rather than assuming id', () => {
|
||||
expect(src).toMatch(/uniqueKeyFor/);
|
||||
});
|
||||
|
||||
it('routes its fixed reads through safePaginate, not a hand-rolled walk', () => {
|
||||
expect(src).toMatch(/require\('\.\.\/src\/utils\/safePaginate'\)/);
|
||||
expect(src).toMatch(/async function pageSafe\(/);
|
||||
// pageSafe must delegate — a second walk implementation is the thing the
|
||||
// A2 order explicitly forbade.
|
||||
expect(src).toMatch(/return paginate\(/);
|
||||
});
|
||||
|
||||
it('exports the named READS the harness measures', () => {
|
||||
const mod = require(path.join('..', '..', 'scripts', name)); // must not execute
|
||||
expect(mod.READS).toBeDefined();
|
||||
for (const fn of Object.values(mod.READS)) expect(typeof fn).toBe('function');
|
||||
});
|
||||
|
||||
it('does NOT run main() on require — importing it must not hit the network or write', () => {
|
||||
// prove-hit-factors and friends write to mc_test_ledger via
|
||||
// tl.recordAndCount, which is the Bonferroni denominator. An unguarded
|
||||
// entrypoint would let the harness inflate the correction just by measuring.
|
||||
expect(src).toMatch(/if \(require\.main === module\)/);
|
||||
expect(src).not.toMatch(/^main\(\)\.catch/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the calibration services load rows through safePaginate', () => {
|
||||
it.each([
|
||||
['calibrationService', '../../src/services/model/calibrationService'],
|
||||
['lowParamService', '../../src/services/model/lowParamService'],
|
||||
])('%s exports loadSettledRows', (label, modPath) => {
|
||||
const mod = require(modPath);
|
||||
expect(typeof mod.loadSettledRows).toBe('function');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['calibrationService', 'src/services/model/calibrationService.js'],
|
||||
['lowParamService', 'src/services/model/lowParamService.js'],
|
||||
])('%s no longer swallows a query error as end-of-data', (label, rel) => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', rel), 'utf8');
|
||||
// The :117 defect, in CODE. Anchored to line-start so the comment that
|
||||
// documents the defect does not count as the defect.
|
||||
expect(src).not.toMatch(/^\s*if \(error \|\| !data/m);
|
||||
expect(src).toMatch(/paginate\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('A2 did not open anything it was not meant to', () => {
|
||||
it('CALIBRATION_DEPLOYED is still empty — no stat serves a calibrated number', () => {
|
||||
const snapshotService = require('../../src/services/snapshotService');
|
||||
expect(snapshotService.CALIBRATION_DEPLOYED).toEqual([]);
|
||||
});
|
||||
|
||||
it('both hits verdicts remain withdrawn', () => {
|
||||
const wv = require('../../src/services/model/withdrawnVerdicts');
|
||||
expect(wv.isWithdrawn('mlb', 'hits', 'defense_by_direction')).toBe(true);
|
||||
expect(wv.isWithdrawn('mlb', 'hits', 'pitcher_contact_profile')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* safePaginate — spec: specs/read-integrity-harness.md §7.
|
||||
* Fake query builders throughout; nothing here touches a network.
|
||||
*/
|
||||
|
||||
const { paginate } = require('../../src/utils/safePaginate');
|
||||
|
||||
/**
|
||||
* A fake PostgREST table. `rows` may be mutated between pages to simulate
|
||||
* concurrent writes. Honours order+range the way Postgres does WITH an ORDER BY.
|
||||
*/
|
||||
function fakeTable(rows, opts = {}) {
|
||||
const state = { rows, orderCalls: 0, rangeCalls: 0 };
|
||||
state.make = () => {
|
||||
let orderKey = null; let asc = true;
|
||||
const q = {
|
||||
order(k, o) { orderKey = k; asc = !o || o.ascending !== false; state.orderCalls += 1; return q; },
|
||||
async range(from, to) {
|
||||
state.rangeCalls += 1;
|
||||
if (opts.errorOnRange && opts.errorOnRange(from)) return { data: null, error: { message: 'connection reset' } };
|
||||
if (opts.beforeRange) opts.beforeRange(from, state);
|
||||
// Unordered simulation: return physical order (the production defect).
|
||||
let out = [...state.rows];
|
||||
if (orderKey) {
|
||||
out.sort((a, b) => (a[orderKey] < b[orderKey] ? -1 : a[orderKey] > b[orderKey] ? 1 : 0));
|
||||
if (!asc) out.reverse();
|
||||
}
|
||||
return { data: out.slice(from, to + 1), error: null };
|
||||
},
|
||||
};
|
||||
return q;
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
const row = (id) => ({ id, v: `r${id}` });
|
||||
const ids = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => row(a + i));
|
||||
|
||||
describe('safePaginate — (a) returns the full set, in order', () => {
|
||||
it('walks every page and returns each row exactly once, ascending by key', async () => {
|
||||
const t = fakeTable(ids(1, 2490));
|
||||
const out = await paginate(t.make, { pageSize: 1000 });
|
||||
expect(out).toHaveLength(2490);
|
||||
expect(new Set(out.map((r) => r.id)).size).toBe(2490);
|
||||
expect(out.map((r) => r.id)).toEqual(ids(1, 2490).map((r) => r.id)); // in order
|
||||
expect(t.rangeCalls).toBe(3);
|
||||
});
|
||||
|
||||
it('always applies the order — every page is ordered, not just the first', async () => {
|
||||
const t = fakeTable(ids(1, 2490));
|
||||
await paginate(t.make, { pageSize: 1000 });
|
||||
expect(t.orderCalls).toBe(t.rangeCalls);
|
||||
});
|
||||
|
||||
it('orders by the configured key, and can descend', async () => {
|
||||
const t = fakeTable(ids(1, 5));
|
||||
const out = await paginate(t.make, { pageSize: 10, ascending: false });
|
||||
expect(out.map((r) => r.id)).toEqual([5, 4, 3, 2, 1]);
|
||||
});
|
||||
|
||||
it('returns [] for an empty table without throwing', async () => {
|
||||
const out = await paginate(fakeTable([]).make, { pageSize: 10 });
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safePaginate — (b) a mid-walk error THROWS, never a silent end', () => {
|
||||
it('throws when page 2 fails instead of returning page 1 as the whole set', async () => {
|
||||
// THE :117 DEFECT: `if (error || !data) break` would return 1,000 rows here
|
||||
// and the caller would fit a map on them believing it had 2,490.
|
||||
const t = fakeTable(ids(1, 2490), { errorOnRange: (from) => from === 1000 });
|
||||
await expect(paginate(t.make, { pageSize: 1000, label: 'cal' }))
|
||||
.rejects.toThrow(/read failed at range 1000-1999 — connection reset/);
|
||||
});
|
||||
|
||||
it('tags the error so a caller can tell a read failure from thin history', async () => {
|
||||
const t = fakeTable(ids(1, 10), { errorOnRange: (from) => from === 0 });
|
||||
await expect(paginate(t.make, { pageSize: 5 })).rejects.toMatchObject({ code: 'READ_FAILED' });
|
||||
});
|
||||
|
||||
it('names itself in the message', async () => {
|
||||
const t = fakeTable(ids(1, 10), { errorOnRange: () => true });
|
||||
await expect(paginate(t.make, { pageSize: 5, label: 'fromLedger(hits)' }))
|
||||
.rejects.toThrow(/^fromLedger\(hits\):/);
|
||||
});
|
||||
|
||||
it('refuses a runaway walk rather than reporting a truncated read as complete', async () => {
|
||||
// Fresh unique ids every page, so the runaway guard is what fires — not the
|
||||
// duplicate guard.
|
||||
let n = 0;
|
||||
const t = {
|
||||
make: () => ({
|
||||
order() { return this; },
|
||||
async range() { const page = ids(n * 10 + 1, n * 10 + 10); n += 1; return { data: page, error: null }; },
|
||||
}),
|
||||
};
|
||||
await expect(paginate(t.make, { pageSize: 10, maxPages: 3 })).rejects.toThrow(/maxPages/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safePaginate — (c) set-identity vs an ordered full-table control', () => {
|
||||
const control = (rows) => [...rows].sort((a, b) => a.id - b.id).map((r) => r.id);
|
||||
|
||||
it('is set-identical to the control on a static table', async () => {
|
||||
const rows = ids(1, 4321);
|
||||
const out = await paginate(fakeTable(rows).make, { pageSize: 1000 });
|
||||
expect(out.map((r) => r.id)).toEqual(control(rows));
|
||||
});
|
||||
|
||||
it('is set-identical when the physical order is shuffled under it', async () => {
|
||||
// The planner returning rows in an arbitrary physical order is exactly the
|
||||
// production condition. A stable ORDER BY makes it irrelevant.
|
||||
const rows = ids(1, 3000);
|
||||
const t = fakeTable([...rows].reverse());
|
||||
const out = await paginate(t.make, { pageSize: 1000 });
|
||||
expect(out.map((r) => r.id)).toEqual(control(rows));
|
||||
});
|
||||
|
||||
it('THROWS on a duplicate key rather than returning a corrupted set', async () => {
|
||||
// A non-unique ordering key: ties may come back in any order, so pages
|
||||
// overlap. An .order() clause on such a column is not a fix.
|
||||
const dup = [{ id: 1 }, { id: 2 }, { id: 2 }, { id: 3 }];
|
||||
await expect(paginate(fakeTable(dup).make, { pageSize: 2 }))
|
||||
.rejects.toThrow(/duplicate key \(id\)=2/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safePaginate — concurrent append (the regression this fix exists for)', () => {
|
||||
it('returns every pre-existing row exactly once while rows are APPENDED mid-walk', async () => {
|
||||
// The snapshot cron appends to ledger_entries at 14/19/22/1/3 UTC. With a
|
||||
// stable ascending key, appended rows land after the cursor: nothing already
|
||||
// returned can be returned again, and nothing pending can be skipped.
|
||||
const original = ids(1, 2500);
|
||||
let next = 10000;
|
||||
const t = fakeTable([...original], {
|
||||
beforeRange: (from, state) => {
|
||||
if (from > 0) state.rows.push(row(next++)); // a write lands mid-walk
|
||||
},
|
||||
});
|
||||
const out = await paginate(t.make, { pageSize: 1000 });
|
||||
const seen = out.map((r) => r.id);
|
||||
|
||||
expect(new Set(seen).size).toBe(seen.length); // no duplicates
|
||||
for (const r of original) expect(seen).toContain(r.id); // nothing dropped
|
||||
});
|
||||
|
||||
it('the OLD unordered walk fails that same simulation — the test has teeth', async () => {
|
||||
// Same table, paged over PHYSICAL order with no ORDER BY. An UPDATE in
|
||||
// Postgres writes a new tuple version at the end of the heap, so a row that
|
||||
// was already returned moves AFTER the cursor (returned twice) and every row
|
||||
// behind it shifts back by one (one falls across the page boundary unseen).
|
||||
// This is the production shape, and it is what the fix removes.
|
||||
const original = ids(1, 2500);
|
||||
const rows = [...original];
|
||||
const unorderedWalk = async () => {
|
||||
const out = [];
|
||||
for (let from = 0; ; from += 1000) {
|
||||
if (from > 0) rows.push(rows.splice(0, 1)[0]); // a row is re-written mid-walk
|
||||
const page = rows.slice(from, from + 1000);
|
||||
if (page.length === 0) break;
|
||||
out.push(...page);
|
||||
if (page.length < 1000) break;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const seen = (await unorderedWalk()).map((r) => r.id);
|
||||
expect(new Set(seen).size).toBeLessThan(seen.length); // duplicates appear
|
||||
const missing = original.filter((r) => !seen.includes(r.id));
|
||||
expect(missing.length).toBeGreaterThan(0); // and rows are lost
|
||||
});
|
||||
});
|
||||
|
||||
// ── FIX A2b — COMPOSITE KEYS ────────────────────────────────────────────────
|
||||
const { normalizeKey } = require('../../src/utils/safePaginate');
|
||||
const { uniqueKeyFor } = require('../../src/utils/tableKeys');
|
||||
|
||||
/** A fake table keyed by a composite tuple, ordered the way Postgres would. */
|
||||
function fakeComposite(rows, opts = {}) {
|
||||
const state = { rows, orderCols: [], rangeCalls: 0 };
|
||||
state.make = () => {
|
||||
const cols = [];
|
||||
const q = {
|
||||
order(c) { cols.push(c); return q; },
|
||||
async range(from, to) {
|
||||
state.rangeCalls += 1;
|
||||
state.orderCols = [...cols];
|
||||
if (opts.errorOnRange && opts.errorOnRange(from)) return { data: null, error: { message: 'reset' } };
|
||||
const out = [...state.rows].sort((a, b) => {
|
||||
for (const c of cols) {
|
||||
if (a[c] < b[c]) return -1;
|
||||
if (a[c] > b[c]) return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return { data: out.slice(from, to + 1), error: null };
|
||||
},
|
||||
};
|
||||
return q;
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
const KEY = ['as_of_date', 'sport', 'season', 'player_key'];
|
||||
const composite = (n) => Array.from({ length: n }, (_, i) => ({
|
||||
as_of_date: `2026-08-${String((i % 9) + 1).padStart(2, '0')}`,
|
||||
sport: 'mlb', season: 2026, player_key: `p${i}`, v: i,
|
||||
}));
|
||||
|
||||
describe('safePaginate — composite keys (A2b)', () => {
|
||||
it('normalizeKey treats a string and a one-column list identically', () => {
|
||||
expect(normalizeKey('id')).toEqual(['id']);
|
||||
expect(normalizeKey(['id'])).toEqual(['id']);
|
||||
expect(normalizeKey(undefined)).toEqual(['id']);
|
||||
expect(normalizeKey(KEY)).toEqual(KEY);
|
||||
});
|
||||
|
||||
it('rejects an empty key rather than silently defaulting to id', () => {
|
||||
expect(() => normalizeKey([])).toThrow(/must be a column name/);
|
||||
expect(() => normalizeKey([null])).toThrow(/must be a column name/);
|
||||
});
|
||||
|
||||
it('returns the full set exactly once, ordered by the whole tuple', async () => {
|
||||
const t = fakeComposite(composite(2500));
|
||||
const rows = await paginate(t.make, { key: KEY, pageSize: 1000 });
|
||||
expect(rows).toHaveLength(2500);
|
||||
expect(new Set(rows.map((r) => r.player_key)).size).toBe(2500);
|
||||
// sorted by as_of_date first
|
||||
for (let i = 1; i < rows.length; i += 1) {
|
||||
expect(rows[i].as_of_date >= rows[i - 1].as_of_date).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('orders EVERY column of the key, in the declared order', async () => {
|
||||
const t = fakeComposite(composite(10));
|
||||
await paginate(t.make, { key: KEY, pageSize: 100 });
|
||||
expect(t.orderCols).toEqual(KEY);
|
||||
});
|
||||
|
||||
it('a mid-walk error still THROWS with a composite key', async () => {
|
||||
const t = fakeComposite(composite(2500), { errorOnRange: (f) => f === 1000 });
|
||||
await expect(paginate(t.make, { key: KEY, pageSize: 1000, label: 'ctx' }))
|
||||
.rejects.toThrow(/ctx: read failed at range 1000-1999/);
|
||||
});
|
||||
|
||||
it('THROWS on a duplicate TUPLE, naming every column', async () => {
|
||||
const dup = [
|
||||
{ as_of_date: '2026-08-01', sport: 'mlb', season: 2026, player_key: 'a' },
|
||||
{ as_of_date: '2026-08-01', sport: 'mlb', season: 2026, player_key: 'a' },
|
||||
];
|
||||
await expect(paginate(fakeComposite(dup).make, { key: KEY, pageSize: 10 }))
|
||||
.rejects.toThrow(/duplicate key \(as_of_date,sport,season,player_key\)/);
|
||||
});
|
||||
|
||||
it('does NOT throw when only a PREFIX of the tuple repeats', async () => {
|
||||
// Same day, same sport, different player — a legitimate row, not a duplicate.
|
||||
const rows = [
|
||||
{ as_of_date: '2026-08-01', sport: 'mlb', season: 2026, player_key: 'a' },
|
||||
{ as_of_date: '2026-08-01', sport: 'mlb', season: 2026, player_key: 'b' },
|
||||
];
|
||||
await expect(paginate(fakeComposite(rows).make, { key: KEY, pageSize: 10 }))
|
||||
.resolves.toHaveLength(2);
|
||||
});
|
||||
|
||||
it('the single-key path is unchanged — backward compatible', async () => {
|
||||
const t = fakeTable(ids(1, 2490));
|
||||
const out = await paginate(t.make, { pageSize: 1000 }); // no key => 'id'
|
||||
expect(out).toHaveLength(2490);
|
||||
expect(new Set(out.map((r) => r.id)).size).toBe(2490);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableKeys — the constraints come from the schema, not a guess', () => {
|
||||
it('gives the real composite key for each context table', () => {
|
||||
expect(uniqueKeyFor('batter_spray')).toEqual(['as_of_date', 'sport', 'season', 'source_id']);
|
||||
expect(uniqueKeyFor('team_defense')).toEqual(['as_of_date', 'sport', 'season', 'team']);
|
||||
expect(uniqueKeyFor('statcast_aggregates')).toEqual(['sport', 'season', 'source_id', 'role']);
|
||||
expect(uniqueKeyFor('platoon_splits')).toEqual(['as_of_date', 'sport', 'season', 'player_key']);
|
||||
expect(uniqueKeyFor('park_dimensions')).toEqual(['as_of_date', 'sport', 'venue_id']);
|
||||
expect(uniqueKeyFor('hitter_opportunity')).toEqual(['as_of_date', 'sport', 'season', 'player_key']);
|
||||
expect(uniqueKeyFor('lineup_context')).toEqual(['as_of_date', 'sport', 'game_pk', 'player_key']);
|
||||
});
|
||||
|
||||
it('keeps the single-column keys single', () => {
|
||||
expect(uniqueKeyFor('ledger_entries')).toEqual(['id']);
|
||||
expect(uniqueKeyFor('model_snapshots')).toEqual(['id']);
|
||||
expect(uniqueKeyFor('game_context')).toEqual(['game_id']);
|
||||
});
|
||||
|
||||
it('THROWS for an unknown table rather than defaulting to id', () => {
|
||||
expect(() => uniqueKeyFor('some_new_table')).toThrow(/no unique key recorded/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* A7 — does the shadow EVIDENCE actually accrue, on the scheduled path?
|
||||
*
|
||||
* A6 measured the shadow firing in a hand-run script. That proves the resolver
|
||||
* works; it does not prove a scheduled snapshot writes anything. This drives the
|
||||
* REAL `gradeAndCacheSlate` -> onGraded -> rowsFromSides chain with injected
|
||||
* deps and asserts a `would_fire` block lands on the retention row.
|
||||
*
|
||||
* The distinction matters: A5 found factors that were built, correct, and never
|
||||
* invoked. Evidence that is generated but never persisted is the same failure
|
||||
* one layer along.
|
||||
*/
|
||||
|
||||
const gradeSlate = require('../../src/services/gradeSlateService');
|
||||
const retention = require('../../src/services/retentionService');
|
||||
const ff = require('../../src/services/model/factorFreeze');
|
||||
const hf = require('../../src/services/model/hitsFactors');
|
||||
const { nameKey } = require('../../src/utils/playerName');
|
||||
|
||||
const JUDGE = nameKey('Aaron Judge');
|
||||
|
||||
const SPRAY = {
|
||||
as_of_date: '2026-08-10', player_key: JUDGE,
|
||||
pull_gb: 0.30, straight_gb: 0.20, oppo_gb: 0.10,
|
||||
pull_air: 0.20, straight_air: 0.10, oppo_air: 0.10,
|
||||
};
|
||||
const OAA = {
|
||||
'3B': { oaa: 2 }, SS: { oaa: 1 }, LF: { oaa: 0 }, '1B': { oaa: 0 },
|
||||
'2B': { oaa: 1 }, RF: { oaa: -1 }, CF: { oaa: 0 },
|
||||
};
|
||||
const SPLITS = { vl: { pa: 120, atBats: 110, hits: 33 }, vr: { pa: 300, atBats: 280, hits: 70 } };
|
||||
|
||||
/** The LIVE context: no opponent, no pitcher — the measured production state. */
|
||||
const liveCtx = () => ({
|
||||
spray: SPRAY, positionOaa: null, bats: 'R', throws: null,
|
||||
pitcherHardHit: null, platoonSplits: SPLITS, archetype: 'BOMBER', as_of: null,
|
||||
});
|
||||
/** The SHADOW context: same indexes, joined through the resolved keys. */
|
||||
const shadowCtx = () => ({ ...liveCtx(), positionOaa: OAA, throws: 'L', pitcherHardHit: 0.45 });
|
||||
|
||||
const KEYS = {
|
||||
opponent: 'Boston Red Sox', opposing_pitcher: 'Chris Sale',
|
||||
source: 'lineup_context+schedule', refused: null,
|
||||
};
|
||||
|
||||
/** A resolver with A6's signature: keys omitted = live, keys supplied = shadow. */
|
||||
const resolver = (prop, sport, keys) => (keys ? shadowCtx() : liveCtx());
|
||||
|
||||
const PROP = {
|
||||
player: 'Aaron Judge', stat_type: 'hits', line: 0.5, book: 'draftkings',
|
||||
over_odds: -120, under_odds: 100, game_time: '2026-08-10T23:05:00Z',
|
||||
};
|
||||
|
||||
/** A grader that runs the REAL engine freeze/shadow logic in miniature. */
|
||||
function fakeGrade(base) {
|
||||
const legacy = {
|
||||
player: base.player, stat_type: base.stat_type, line: base.line,
|
||||
direction: base.direction, grade: 'B', confidence: 70, p_win: 0.55,
|
||||
};
|
||||
// This mirrors analyzeViaEngine1's A5/A6 block exactly.
|
||||
if (String(base.stat_type).toLowerCase() === 'hits' && base.factor_context) {
|
||||
const live = hf.adjustProbability(0.55, base.factor_context);
|
||||
if (live.p_adjusted != null && live.factors_fired > 0) legacy.p_win = live.p_adjusted;
|
||||
let shadow = null;
|
||||
const keys = base.matchup_keys || null;
|
||||
if (keys && (keys.opponent || keys.opposing_pitcher) && typeof base.factor_context_resolver === 'function') {
|
||||
const sc = base.factor_context_resolver(base, base.sport, keys);
|
||||
if (sc) shadow = ff.shadowBlock(sc, hf.hitsFactorMultiplier(sc), keys);
|
||||
}
|
||||
legacy.factor_inputs = ff.freeze(base.factor_context, base, shadow);
|
||||
}
|
||||
return legacy;
|
||||
}
|
||||
|
||||
async function runSlate(opts = {}) {
|
||||
const collected = [];
|
||||
await gradeSlate.gradeAndCacheSlate('mlb', [PROP], {
|
||||
grade: fakeGrade,
|
||||
factorContext: resolver,
|
||||
matchupKeys: opts.matchupKeys !== undefined ? opts.matchupKeys : (() => KEYS),
|
||||
cacheSet: async () => {},
|
||||
onGraded: (base, sides) => {
|
||||
collected.push(...retention.rowsFromSides(base, sides, {
|
||||
snapshotId: 's1', capturedAt: '2026-08-10T14:00:00Z', sport: 'mlb', gameDate: '2026-08-10',
|
||||
}));
|
||||
},
|
||||
...opts.slate,
|
||||
});
|
||||
return collected;
|
||||
}
|
||||
|
||||
describe('the shadow accrues through the REAL slate path', () => {
|
||||
it('a graded hits row lands with would_fire on it', async () => {
|
||||
const rows = await runSlate();
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
const row = rows.find((r) => r.factor_inputs);
|
||||
expect(row).toBeDefined();
|
||||
expect(row.factor_inputs.would_fire).not.toBeNull();
|
||||
expect(row.factor_inputs.would_fire.factors_fired).toBeGreaterThan(0);
|
||||
expect(row.factor_inputs.would_fire.keys.opponent).toBe('Boston Red Sox');
|
||||
});
|
||||
|
||||
it('the row also carries the LIVE refusal — both states, side by side', async () => {
|
||||
const rows = await runSlate();
|
||||
const fi = rows.find((r) => r.factor_inputs).factor_inputs;
|
||||
expect(fi.position_oaa).toBeNull(); // what the grade saw
|
||||
expect(fi.throws).toBeNull();
|
||||
expect(fi.would_fire.shadow_inputs.throws).toBe('L'); // what it would have
|
||||
});
|
||||
|
||||
it('would_fire stays RE-DERIVABLE from what is stored beside it', async () => {
|
||||
const rows = await runSlate();
|
||||
const fi = rows.find((r) => r.factor_inputs).factor_inputs;
|
||||
const re = hf.hitsFactorMultiplier({
|
||||
spray: fi.spray, bats: fi.bats,
|
||||
platoonSplits: fi.platoon
|
||||
? { vl: { pa: fi.platoon.vl.pa, atBats: fi.platoon.vl.ab, hits: fi.platoon.vl.hits },
|
||||
vr: { pa: fi.platoon.vr.pa, atBats: fi.platoon.vr.ab, hits: fi.platoon.vr.hits } }
|
||||
: null,
|
||||
positionOaa: fi.would_fire.shadow_inputs.position_oaa,
|
||||
throws: fi.would_fire.shadow_inputs.throws,
|
||||
pitcherHardHit: fi.would_fire.shadow_inputs.pitcher_hard_hit,
|
||||
});
|
||||
expect(re.multiplier).toBe(fi.would_fire.multiplier);
|
||||
});
|
||||
|
||||
it('THE SERVED GRADE IS UNTOUCHED — p_win is the unadjusted forecast', async () => {
|
||||
const rows = await runSlate();
|
||||
for (const r of rows) expect(Number(r.p_win)).toBe(0.55);
|
||||
});
|
||||
|
||||
it('no matchupKeys resolver => no would_fire, and the grade is the same', async () => {
|
||||
const rows = await runSlate({ matchupKeys: null });
|
||||
const fi = rows.find((r) => r.factor_inputs).factor_inputs;
|
||||
expect(fi.would_fire).toBeNull();
|
||||
for (const r of rows) expect(Number(r.p_win)).toBe(0.55);
|
||||
});
|
||||
|
||||
it('refused keys => no shadow fire, never a guess', async () => {
|
||||
const refused = { opponent: null, opposing_pitcher: null, source: null, refused: 'no_lineup_row' };
|
||||
const rows = await runSlate({ matchupKeys: () => refused });
|
||||
expect(rows.find((r) => r.factor_inputs).factor_inputs.would_fire).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the scheduled path carries the shadow (not just a hand-run script)', () => {
|
||||
const src = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', '..', 'src/services/snapshotService.js'), 'utf8');
|
||||
|
||||
it('runSnapshot builds the key index and passes it to the grader', () => {
|
||||
expect(src).toMatch(/matchupKeys/);
|
||||
expect(src).toMatch(/gradeAndCacheSlate\(sp, props, \{\s*\n\s*factorContext,\s*\n\s*matchupKeys,/);
|
||||
});
|
||||
|
||||
it('the index is built BEFORE the grade call, not after', () => {
|
||||
expect(src.indexOf('FIX A6 — THE JOIN KEYS')).toBeLessThan(src.indexOf('await deps.gradeAndCacheSlate('));
|
||||
});
|
||||
|
||||
it('runAllSnapshots (the cron entrypoint) routes through runSnapshot', () => {
|
||||
expect(src).toMatch(/async function runAllSnapshots/);
|
||||
expect(src).toMatch(/runSnapshot\(/);
|
||||
});
|
||||
|
||||
it('a key-resolve failure degrades to no-shadow, never breaking the slate', () => {
|
||||
const i = src.indexOf('FIX A6 — THE JOIN KEYS');
|
||||
const block = src.slice(i, i + 1600);
|
||||
expect(block).toMatch(/catch \(e\)/);
|
||||
expect(block).toMatch(/matchupKeys = null|no key index/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* snapshotSettlementService — Fix A3.
|
||||
* Every dependency injected; nothing here touches a network or a database.
|
||||
*/
|
||||
|
||||
const svc = require('../../src/services/snapshotSettlementService');
|
||||
|
||||
const line = (over) => ({ hits: over, totalBases: over, rbi: over, runs: over });
|
||||
|
||||
describe('decide — side-aligned outcomes', () => {
|
||||
const base = { id: 1, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a' };
|
||||
const lines = { '2026-08-05|a': line(2) };
|
||||
|
||||
it('an OVER that cleared the line is a hit', () => {
|
||||
const { updates } = svc.decide([{ ...base, line: 0.5, side: 'over' }], lines);
|
||||
expect(updates).toEqual([{ id: 1, outcome: 'hit', actual_value: 2 }]);
|
||||
});
|
||||
|
||||
it('an UNDER on the SAME row is the mirror — a miss, not a hit', () => {
|
||||
// A raw `realized > line` indicator is the OVER perspective and would invert
|
||||
// the target on every under row.
|
||||
const { updates } = svc.decide([{ ...base, line: 0.5, side: 'under' }], lines);
|
||||
expect(updates).toEqual([{ id: 1, outcome: 'miss', actual_value: 2 }]);
|
||||
});
|
||||
|
||||
it('an UNDER that held is a hit', () => {
|
||||
const { updates } = svc.decide([{ ...base, line: 3.5, side: 'under' }], lines);
|
||||
expect(updates[0].outcome).toBe('hit');
|
||||
});
|
||||
|
||||
it('actual_value is the RAW realized stat, unopinionated by side', () => {
|
||||
const o = svc.decide([{ ...base, line: 0.5, side: 'over' }], lines).updates[0];
|
||||
const u = svc.decide([{ ...base, line: 0.5, side: 'under' }], lines).updates[0];
|
||||
expect(o.actual_value).toBe(2);
|
||||
expect(u.actual_value).toBe(2);
|
||||
});
|
||||
|
||||
it('reads the right box-score field per stat', () => {
|
||||
const l = { '2026-08-05|a': { hits: 1, totalBases: 4, rbi: 3, runs: 0 } };
|
||||
const at = (stat) => svc.decide([{ ...base, stat, line: 1.5, side: 'over' }], l).updates[0];
|
||||
expect(at('total_bases').actual_value).toBe(4);
|
||||
expect(at('rbi').actual_value).toBe(3);
|
||||
// hits=1 does not clear 1.5 — that is a settled MISS, not an absence.
|
||||
expect(at('hits')).toEqual({ id: 1, outcome: 'miss', actual_value: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('decide — a row logged after first pitch is not a prediction', () => {
|
||||
const lines = { '2026-08-05|a': line(2) };
|
||||
const row = (capturedAt) => ({ id: 1, game_date: '2026-08-05', captured_at: capturedAt, stat: 'hits', player_key: 'a', line: 0.5, side: 'over' });
|
||||
|
||||
it('accepts a capture from the morning of the game', () => {
|
||||
// 14:00 UTC = 10:00 ET, well before first pitch.
|
||||
expect(svc.decide([row('2026-08-05T14:00:00Z')], lines).counts.settled).toBe(1);
|
||||
});
|
||||
|
||||
it('accepts a capture from the day before', () => {
|
||||
expect(svc.decide([row('2026-08-04T22:00:00Z')], lines).counts.settled).toBe(1);
|
||||
});
|
||||
|
||||
it('REFUSES a 01:00-UTC capture, which is 21:00 ET the same evening — in-game', () => {
|
||||
// The trap: a UTC date compare would keep this row.
|
||||
const r = svc.decide([{ ...row('2026-08-06T01:00:00Z'), game_date: '2026-08-05' }], lines);
|
||||
expect(r.counts.settled).toBe(0);
|
||||
expect(r.counts.post_hoc_logged).toBe(1);
|
||||
});
|
||||
|
||||
it('REFUSES a capture from the following day', () => {
|
||||
const r = svc.decide([row('2026-08-06T14:00:00Z')], lines);
|
||||
expect(r.counts.post_hoc_logged).toBe(1);
|
||||
});
|
||||
|
||||
it('isPreGame is absent-safe', () => {
|
||||
expect(svc.isPreGame(null, '2026-08-05')).toBe(false);
|
||||
expect(svc.isPreGame('2026-08-05T14:00:00Z', null)).toBe(false);
|
||||
expect(svc.isPreGame('not-a-date', '2026-08-05')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decide — integrity', () => {
|
||||
it('every candidate lands in exactly one bucket (conservation)', () => {
|
||||
const lines = { '2026-08-05|a': line(2) };
|
||||
const rows = [
|
||||
{ id: 1, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' },
|
||||
{ id: 2, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'ghost', line: 0.5, side: 'over' },
|
||||
{ id: 3, game_date: '2026-08-05', captured_at: '2026-08-06T01:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' },
|
||||
{ id: 4, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: null, side: 'over' },
|
||||
];
|
||||
const { counts } = svc.decide(rows, lines);
|
||||
expect(counts.settled).toBe(1);
|
||||
expect(counts.orphaned).toBe(1);
|
||||
expect(counts.unresolvable).toBe(2);
|
||||
expect(counts.settled + counts.orphaned + counts.unresolvable).toBe(counts.candidates);
|
||||
});
|
||||
|
||||
it('THROWS on a duplicate row id — a doubled prediction poisons every measure', () => {
|
||||
const r = { id: 7, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' };
|
||||
expect(() => svc.decide([r, r], { '2026-08-05|a': line(2) })).toThrow(/duplicate snapshot id 7/);
|
||||
});
|
||||
|
||||
it('a player with no box-score line is ORPHANED, never settled at zero', () => {
|
||||
const r = svc.decide([{ id: 1, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'nobody', line: 0.5, side: 'over' }], {});
|
||||
expect(r.counts.orphaned).toBe(1);
|
||||
expect(r.updates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('battingLines — doubleheaders sum, non-batters are absent', () => {
|
||||
const sched = (pks) => ({ dates: [{ games: pks.map((pk) => ({ gamePk: pk, officialDate: '2026-08-05', status: { detailedState: 'Final' } })) }] });
|
||||
const box = (rows) => ({
|
||||
teams: {
|
||||
home: { batters: rows.map((_, i) => i + 1), players: Object.fromEntries(rows.map((r, i) => [`ID${i + 1}`, { person: { fullName: r.name }, stats: { batting: r.b } }])) },
|
||||
away: { batters: [], players: {} },
|
||||
},
|
||||
});
|
||||
|
||||
it('sums both games of a doubleheader — the prop covers the day', async () => {
|
||||
const getJson = async (url) => {
|
||||
if (url.includes('schedule')) return sched([1, 2]);
|
||||
return box([{ name: 'Aaron Judge', b: { atBats: 4, hits: 1, totalBases: 2, rbi: 1, runs: 0 } }]);
|
||||
};
|
||||
const lines = await svc.battingLines(['2026-08-05'], { getJson });
|
||||
const k = Object.keys(lines)[0];
|
||||
expect(lines[k].hits).toBe(2); // 1 + 1
|
||||
expect(lines[k].totalBases).toBe(4);
|
||||
});
|
||||
|
||||
it('omits a player who did not bat (atBats null) rather than recording zero', async () => {
|
||||
const getJson = async (url) => (url.includes('schedule') ? sched([1])
|
||||
: box([{ name: 'Benchy McBench', b: { atBats: null } }]));
|
||||
const lines = await svc.battingLines(['2026-08-05'], { getJson });
|
||||
expect(Object.keys(lines)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores a game that is not Final', async () => {
|
||||
const getJson = async (url) => (url.includes('schedule')
|
||||
? { dates: [{ games: [{ gamePk: 1, status: { detailedState: 'In Progress' } }] }] } : box([]));
|
||||
expect(Object.keys(await svc.battingLines(['2026-08-05'], { getJson }))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a failed date is absent, not fatal', async () => {
|
||||
const getJson = async () => { throw new Error('502'); };
|
||||
await expect(svc.battingLines(['2026-08-05'], { getJson })).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('settleSnapshots — orchestration', () => {
|
||||
const fakeSb = (rows, captured = []) => ({
|
||||
from() {
|
||||
const q = {
|
||||
select() { return q; }, eq() { return q; }, in() { return q; },
|
||||
is() { return q; }, lt() { return q; }, order() { return q; },
|
||||
async range(from, to) { return { data: rows.slice(from, to + 1), error: null }; },
|
||||
update(patch) { return { eq: (_c, id) => ({ is: async () => { captured.push({ id, patch }); return { error: null }; } }) }; },
|
||||
};
|
||||
return q;
|
||||
},
|
||||
});
|
||||
|
||||
it('no client → skipped, never a silent zero', async () => {
|
||||
expect(await svc.settleSnapshots({})).toMatchObject({ skipped: 'supabase not configured' });
|
||||
});
|
||||
|
||||
it('settles and reports repaired-champion rows separately', async () => {
|
||||
const rows = [
|
||||
{ id: 1, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over', model_version: 'engine1@2026-08-07-fullwindow' },
|
||||
{ id: 2, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over', model_version: 'engine1@2026-07-20' },
|
||||
];
|
||||
const captured = [];
|
||||
const res = await svc.settleSnapshots({
|
||||
sb: fakeSb(rows, captured),
|
||||
now: () => new Date('2026-08-09T12:00:00Z'),
|
||||
getJson: async (url) => (url.includes('schedule')
|
||||
? { dates: [{ games: [{ gamePk: 1, officialDate: '2026-08-05', status: { detailedState: 'Final' } }] }] }
|
||||
: { teams: { home: { batters: [1], players: { ID1: { person: { fullName: 'A' }, stats: { batting: { atBats: 4, hits: 2, totalBases: 2, rbi: 0, runs: 0 } } } } }, away: { batters: [], players: {} } } }),
|
||||
});
|
||||
expect(res.written).toBe(2);
|
||||
expect(res.repaired_champion_settled).toBe(1);
|
||||
// Real outcomes, not nulls.
|
||||
for (const c of captured) {
|
||||
expect(c.patch.outcome).toBe('hit');
|
||||
expect(c.patch.actual_value).toBe(2);
|
||||
expect(c.patch.settlement_source).toBe('statsapi_boxscore');
|
||||
}
|
||||
});
|
||||
|
||||
it('bounds the dates per run and drains NEWEST first (oldest-first deadlocks)', async () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: i + 1, game_date: `2026-07-${String(i + 10).padStart(2, '0')}`,
|
||||
captured_at: `2026-07-${String(i + 10).padStart(2, '0')}T14:00:00Z`,
|
||||
stat: 'hits', player_key: 'a', line: 0.5, side: 'over',
|
||||
}));
|
||||
const res = await svc.settleSnapshots({
|
||||
sb: fakeSb(rows), now: () => new Date('2026-08-09T12:00:00Z'),
|
||||
maxDates: 3, maxWindows: 1, getJson: async () => ({ dates: [] }),
|
||||
});
|
||||
expect(res.dates).toHaveLength(3);
|
||||
expect(res.dates_remaining).toBe(9);
|
||||
// Newest first: an old date whose remaining rows are all post-hoc or
|
||||
// orphaned would otherwise re-process forever and block the window.
|
||||
expect(res.dates[0]).toBe('2026-07-21');
|
||||
});
|
||||
|
||||
it('ADVANCES past a drained window so older settleable dates are reached', async () => {
|
||||
// The newest dates keep rows that can never settle (no box-score line), so a
|
||||
// fixed window would sit on them forever. Only the OLDEST date here has a
|
||||
// matching box score; the run must walk to it.
|
||||
const rows = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: i + 1, game_date: `2026-08-0${i + 1}`, captured_at: `2026-08-0${i + 1}T14:00:00Z`,
|
||||
stat: 'hits', player_key: i === 0 ? 'a' : `ghost${i}`, line: 0.5, side: 'over',
|
||||
}));
|
||||
const res = await svc.settleSnapshots({
|
||||
sb: fakeSb(rows), now: () => new Date('2026-08-09T12:00:00Z'),
|
||||
maxDates: 1, maxWindows: 4,
|
||||
getJson: async (url) => (url.includes('schedule')
|
||||
? { dates: [{ games: [{ gamePk: 1, officialDate: '2026-08-01', status: { detailedState: 'Final' } }] }] }
|
||||
: { teams: { home: { batters: [1], players: { ID1: { person: { fullName: 'A' }, stats: { batting: { atBats: 4, hits: 2, totalBases: 2, rbi: 0, runs: 0 } } } } }, away: { batters: [], players: {} } } }),
|
||||
});
|
||||
expect(res.windows_scanned).toBe(4); // walked past three drained windows
|
||||
expect(res.written).toBe(1);
|
||||
expect(res.dates).toEqual(['2026-08-01']);
|
||||
});
|
||||
|
||||
it('counts permanently-unsettleable rows rather than hiding them', async () => {
|
||||
const rows = [
|
||||
// logged at 01:00 UTC = 21:00 ET the same evening — in-game, never a prediction
|
||||
{ id: 1, game_date: '2026-08-05', captured_at: '2026-08-06T01:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' },
|
||||
{ id: 2, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' },
|
||||
];
|
||||
const res = await svc.settleSnapshots({
|
||||
sb: fakeSb(rows), now: () => new Date('2026-08-09T12:00:00Z'),
|
||||
getJson: async () => ({ dates: [] }),
|
||||
});
|
||||
expect(res.permanently_unsettleable).toBe(1);
|
||||
});
|
||||
|
||||
it('dry run writes nothing', async () => {
|
||||
const captured = [];
|
||||
const rows = [{ id: 1, game_date: '2026-08-05', captured_at: '2026-08-05T14:00:00Z', stat: 'hits', player_key: 'a', line: 0.5, side: 'over' }];
|
||||
const res = await svc.settleSnapshots({
|
||||
sb: fakeSb(rows, captured), write: false, now: () => new Date('2026-08-09T12:00:00Z'),
|
||||
getJson: async () => ({ dates: [] }),
|
||||
});
|
||||
expect(res.mode).toBe('dry-run');
|
||||
expect(captured).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settlement does NOT reconstruct context', () => {
|
||||
it('touches no context table and no context column', () => {
|
||||
const src = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '..', '..', 'src/services/snapshotSettlementService.js'), 'utf8');
|
||||
for (const t of ['batter_spray', 'team_defense', 'platoon_splits', 'park_dimensions',
|
||||
'game_context', 'hitter_opportunity', 'statcast_aggregates', 'lineup_context']) {
|
||||
expect(src).not.toContain(`'${t}'`);
|
||||
}
|
||||
// It writes outcome columns only.
|
||||
expect(src).toMatch(/outcome: u\.outcome/);
|
||||
expect(src).not.toMatch(/features:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
|
||||
/** The withdrawal record — spec: specs/read-integrity-harness.md §6. */
|
||||
|
||||
const wv = require('../../src/services/model/withdrawnVerdicts');
|
||||
|
||||
describe('withdrawnVerdicts', () => {
|
||||
it('records BOTH hits PROVES as withdrawn', () => {
|
||||
expect(wv.isWithdrawn('mlb', 'hits', 'defense_by_direction')).toBe(true);
|
||||
expect(wv.isWithdrawn('mlb', 'hits', 'pitcher_contact_profile')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not withdraw a factor that was never proven', () => {
|
||||
// platoon_severity never returned PROVES (n=452 < 500). Withdrawing it would
|
||||
// imply it once stood, which is its own falsehood.
|
||||
expect(wv.isWithdrawn('mlb', 'hits', 'platoon_severity')).toBe(false);
|
||||
expect(wv.isWithdrawn('mlb', 'hits', 'park_hits')).toBe(false);
|
||||
});
|
||||
|
||||
it('is scoped per stat — a hits withdrawal says nothing about total_bases', () => {
|
||||
expect(wv.isWithdrawn('mlb', 'total_bases', 'defense_by_direction')).toBe(false);
|
||||
});
|
||||
|
||||
it('APPENDS rather than replaces: the original verdict and evidence survive', () => {
|
||||
for (const w of wv.WITHDRAWALS) {
|
||||
expect(w.original_verdict).toBe('PROVES');
|
||||
expect(w.original_evidence.n).toBeGreaterThan(0);
|
||||
expect(Array.isArray(w.original_evidence.ci)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('every withdrawal carries a measured cause and implicated readers', () => {
|
||||
for (const w of wv.WITHDRAWALS) {
|
||||
expect(w.cause).toMatch(/unordered-pagination/);
|
||||
expect(w.cause).toMatch(/2026-08-09/);
|
||||
expect(w.readers_implicated.length).toBeGreaterThan(0);
|
||||
expect(w.status).toBe(wv.STATUS.WITHDRAWN_PENDING_REAUDIT);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the eligibility arithmetic for defense_by_direction AS arithmetic', () => {
|
||||
const d = wv.WITHDRAWALS.find((w) => w.factor === 'defense_by_direction');
|
||||
expect(d.eligibility_note).toMatch(/422/);
|
||||
expect(d.eligibility_note).toMatch(/not a re-measurement/);
|
||||
});
|
||||
|
||||
it('records the honesty gap: both are STILL SERVED despite withdrawal', () => {
|
||||
const served = wv.servedButWithdrawn();
|
||||
expect(served).toHaveLength(2);
|
||||
for (const w of served) expect(w.still_served_note).toMatch(/hitsFactors/);
|
||||
});
|
||||
|
||||
it('states a reinstatement path that requires a measured clean read', () => {
|
||||
for (const w of wv.WITHDRAWALS) expect(w.reinstatement).toMatch(/readIntegrity/);
|
||||
});
|
||||
|
||||
it('is frozen — a withdrawal must not be mutated in place', () => {
|
||||
expect(Object.isFrozen(wv.WITHDRAWALS)).toBe(true);
|
||||
for (const w of wv.WITHDRAWALS) expect(Object.isFrozen(w)).toBe(true);
|
||||
});
|
||||
|
||||
it('withdrawalsFor filters by sport and stat', () => {
|
||||
expect(wv.withdrawalsFor('mlb', 'hits')).toHaveLength(2);
|
||||
expect(wv.withdrawalsFor('mlb', 'runs')).toHaveLength(0);
|
||||
expect(wv.withdrawalsFor('mlb')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user