STOP voiding on player-absence — it destroyed real results

Correctness fix to code I shipped minutes ago. The induced live settle
pass voided 64 rows as 'player_dnp' and a large share of them are WRONG:
the Jul 18 set is everyday starters (Freeman, Bellinger, Tucker, Chisholm,
Conforto). They played.

ROOT CAUSE — and my Phase 0 diagnosis was wrong. It is not DNP. The
ledger row's game_date is WRONG. ledgerService derives game_date from the
GRADE timestamp when the feed carries no game_time, and a 01:00/03:00 UTC
snapshot is 21:00/23:00 ET the PREVIOUS day, so rows get labelled with the
previous ET date. Verified against fresh season logs (cache disabled, so
not staleness; found:true, so not name resolution):
  Freddie Freeman  played Jul 17 and Jul 19 (x2, doubleheader) — NOT Jul 18
  Steven Kwan      played Jul 18 (x2) and Jul 19               — NOT Jul 17
Settlement was correct to find no game on the labelled date. My void logic
then converted a data-labelling bug into destroyed results.

FIX: never void on player-absence alone. Voiding now requires POSITIVE
evidence — the games themselves postponed/cancelled. Absence returns
'unknown' (reason player_absent_unconfirmed), so the row retries and ages
out to 'unrecoverable' at the cap. We cannot distinguish "did not play"
from "mislabelled date", so we must not claim DNP. Both terminal states
are excluded from the record denominator either way.

Window-decay remains genuinely fixed (full season log vs a rolling
window), and terminal states still prevent immortal rows.

NOT DONE HERE: the 64 wrong voids are still in the table, and the
game_date derivation is still wrong at the source. Both are reported for
the table — no healing in this order.

Suite green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
Kev
2026-07-20 03:10:34 -04:00
parent d4a6170ffa
commit 270c4db47a
3 changed files with 29 additions and 13 deletions
+15 -6
View File
@@ -131,12 +131,21 @@ async function resolveOutcome(args = {}) {
}
if (meaning === 'void') {
const allVoid = games.length > 0 && games.map(classifyGameState).every((s) => s === 'void');
return {
state: 'void',
value: null,
reason: allVoid ? 'game_postponed_or_cancelled' : 'player_dnp',
source: 'schedule',
};
// POSITIVE evidence only: the games themselves were postponed/cancelled.
if (allVoid) {
return { state: 'void', value: null, reason: 'game_postponed_or_cancelled', source: 'schedule' };
}
// ── DO NOT VOID ON PLAYER-ABSENCE ALONE. ──────────────────────────────
// This branch used to return void/'player_dnp' and it was WRONG: absence
// from a date can equally mean the ROW'S DATE IS WRONG. Verified live —
// Freddie Freeman's 97-game log has no 2026-07-18 because he played the
// 17th and 19th (a doubleheader), and ledgerService derives game_date from
// the GRADE timestamp when the feed carries no game_time, so a 01:00/03:00
// UTC snapshot labels rows with the PREVIOUS ET day.
// We cannot distinguish "did not play" from "mislabelled date" here, so we
// must not claim DNP. Unresolvable rows age out to 'unrecoverable' via the
// retry cap — honest, and excluded from the record either way.
return { state: 'unknown', value: null, reason: 'player_absent_unconfirmed', source: 'schedule' };
}
return { state: 'unknown', value: null, reason: 'state_undetermined', source: 'schedule' };
}
+5 -4
View File
@@ -179,7 +179,7 @@ describe('settleLedger — outcome + CLV vs the real result', () => {
// Session 64 — BEHAVIOUR CHANGED ON PURPOSE. A missing game-log row used to
// mean "pending forever". It now depends on what the day's games actually did.
test('no row + game FINAL → VOID (confirmed DNP), no longer immortal', async () => {
test('no row + game FINAL → NOT voided (date may be wrong); attempts bump', async () => {
const sb = fakeSb();
sb._state.selectResults = [
[{ id: 'r1' }],
@@ -190,9 +190,10 @@ describe('settleLedger — outcome + CLV vs the real result', () => {
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
getSchedule: async () => [{ status: 'Final' }],
});
expect(res.voided).toBe(1);
expect(res.voided).toBe(0);
expect(res.settled).toBe(0);
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'void', settlement_source: 'player_dnp' });
expect(res.pending).toBe(1);
expect(sb._calls.updates[0].values.outcome).toBeUndefined();
});
test('no row + game NOT FINAL → stays pending, never voided', async () => {
@@ -222,7 +223,7 @@ describe('settleLedger — outcome + CLV vs the real result', () => {
const getPlayerStats = async () => ({ found: true, last10: [] });
const res = await ledger.settleLedger('mlb', {
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
getSchedule: async () => [], // nothing knowable
getSchedule: async () => [{ status: 'Final' }], // player absent, unconfirmed
});
expect(res.unrecoverable).toBe(1);
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' });
+9 -3
View File
@@ -71,14 +71,20 @@ describe('resolveOutcome', () => {
expect(r.value).toBe(0);
});
test('DNP on a final game → VOID', async () => {
// Session 64 — this MUST NOT void. Player-absence from a labelled date can
// equally mean the row's date is wrong (ledger derives game_date from the
// GRADE time when the feed has no game_time, so late-UTC snapshots label the
// previous ET day). Verified live: Freddie Freeman's 97-game log has no
// 2026-07-18 because he played the 17th and 19th. Voiding on absence
// destroyed real results.
test('player absent on a final game → UNKNOWN, never void (date may be wrong)', async () => {
const r = await src.resolveOutcome({
...base,
getFullLog: async () => [{ date: '2026-07-16', stat: { doubles: 0 } }],
getSchedule: async () => [{ status: 'Final' }],
});
expect(r.state).toBe('void');
expect(r.reason).toBe('player_dnp');
expect(r.state).toBe('unknown');
expect(r.reason).toBe('player_absent_unconfirmed');
});
test('postponed game → VOID with the postponed reason', async () => {