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,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:/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user