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:
@@ -131,12 +131,21 @@ async function resolveOutcome(args = {}) {
|
|||||||
}
|
}
|
||||||
if (meaning === 'void') {
|
if (meaning === 'void') {
|
||||||
const allVoid = games.length > 0 && games.map(classifyGameState).every((s) => s === 'void');
|
const allVoid = games.length > 0 && games.map(classifyGameState).every((s) => s === 'void');
|
||||||
return {
|
// POSITIVE evidence only: the games themselves were postponed/cancelled.
|
||||||
state: 'void',
|
if (allVoid) {
|
||||||
value: null,
|
return { state: 'void', value: null, reason: 'game_postponed_or_cancelled', source: 'schedule' };
|
||||||
reason: allVoid ? 'game_postponed_or_cancelled' : 'player_dnp',
|
}
|
||||||
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' };
|
return { state: 'unknown', value: null, reason: 'state_undetermined', source: 'schedule' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// 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();
|
const sb = fakeSb();
|
||||||
sb._state.selectResults = [
|
sb._state.selectResults = [
|
||||||
[{ id: 'r1' }],
|
[{ id: 'r1' }],
|
||||||
@@ -190,9 +190,10 @@ describe('settleLedger — outcome + CLV vs the real result', () => {
|
|||||||
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
||||||
getSchedule: async () => [{ status: 'Final' }],
|
getSchedule: async () => [{ status: 'Final' }],
|
||||||
});
|
});
|
||||||
expect(res.voided).toBe(1);
|
expect(res.voided).toBe(0);
|
||||||
expect(res.settled).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 () => {
|
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 getPlayerStats = async () => ({ found: true, last10: [] });
|
||||||
const res = await ledger.settleLedger('mlb', {
|
const res = await ledger.settleLedger('mlb', {
|
||||||
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
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(res.unrecoverable).toBe(1);
|
||||||
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' });
|
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' });
|
||||||
|
|||||||
@@ -71,14 +71,20 @@ describe('resolveOutcome', () => {
|
|||||||
expect(r.value).toBe(0);
|
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({
|
const r = await src.resolveOutcome({
|
||||||
...base,
|
...base,
|
||||||
getFullLog: async () => [{ date: '2026-07-16', stat: { doubles: 0 } }],
|
getFullLog: async () => [{ date: '2026-07-16', stat: { doubles: 0 } }],
|
||||||
getSchedule: async () => [{ status: 'Final' }],
|
getSchedule: async () => [{ status: 'Final' }],
|
||||||
});
|
});
|
||||||
expect(r.state).toBe('void');
|
expect(r.state).toBe('unknown');
|
||||||
expect(r.reason).toBe('player_dnp');
|
expect(r.reason).toBe('player_absent_unconfirmed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('postponed game → VOID with the postponed reason', async () => {
|
test('postponed game → VOID with the postponed reason', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user