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:
Kev
2026-08-11 22:49:56 -04:00
parent 387ae4d54e
commit f61ec6b391
49 changed files with 4874 additions and 308 deletions
@@ -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:/);
});
});