270c4db47a
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
418 lines
19 KiB
JavaScript
418 lines
19 KiB
JavaScript
// Session 58 (Phase 1) — ledgerService: the truth infrastructure.
|
|
// Everything runs against a fake Supabase client — zero network, zero env.
|
|
|
|
const ledger = require('../../src/services/ledgerService');
|
|
const { rowsFromSnapshot, computeClv, clvResultOf } = ledger.__internals;
|
|
|
|
// ---- fake Supabase client ------------------------------------------------
|
|
// Minimal chainable stub for the exact query shapes ledgerService uses.
|
|
function fakeSb() {
|
|
const calls = { upserts: [], updates: [] };
|
|
const state = { selectResults: [], selectCursor: 0, countResult: 0 };
|
|
|
|
function builder() {
|
|
const b = {
|
|
_update: null,
|
|
upsert(rows, opts) {
|
|
calls.upserts.push({ rows, opts });
|
|
return Promise.resolve({ error: null });
|
|
},
|
|
update(values) { b._update = values; return b; },
|
|
select() { return b; },
|
|
eq() { return b; },
|
|
is() { return b; },
|
|
not() { return b; },
|
|
lt() { return b; },
|
|
gte() { return b; },
|
|
gt() { return b; },
|
|
in(col, ids) {
|
|
if (b._update) {
|
|
calls.updates.push({ values: b._update, ids });
|
|
return Promise.resolve({ error: null });
|
|
}
|
|
return terminal();
|
|
},
|
|
order() { return b; },
|
|
limit() { return terminal(); },
|
|
then(resolve, reject) { return terminal().then(resolve, reject); },
|
|
};
|
|
function terminal() {
|
|
if (b._update) {
|
|
calls.updates.push({ values: b._update });
|
|
return Promise.resolve({ error: null });
|
|
}
|
|
const data = state.selectResults[state.selectCursor] ?? [];
|
|
state.selectCursor += 1;
|
|
return Promise.resolve({ data, error: null, count: state.countResult });
|
|
}
|
|
return b;
|
|
}
|
|
|
|
return { from: () => builder(), _calls: calls, _state: state };
|
|
}
|
|
|
|
// ---- fixtures --------------------------------------------------------------
|
|
const NOW = '2026-07-10T18:00:00.000Z';
|
|
const GRADE = {
|
|
player: 'Aaron Judge', player_name: 'Aaron Judge', stat_type: 'home_runs',
|
|
line: 0.5, direction: 'over', grade: 'A', confidence: 78, edge_pct: 12.4,
|
|
projection: 0.9,
|
|
gradedAt: { line: 0.5, odds: -115, timestamp: NOW },
|
|
};
|
|
const PROP = {
|
|
player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5,
|
|
home_team: 'NYY', away_team: 'BOS', game_time: '2026-07-10T23:05:00Z',
|
|
book: 'draftkings', over_odds: -115, under_odds: -105,
|
|
};
|
|
|
|
describe('rowsFromSnapshot — the write shape', () => {
|
|
test('builds a public row with REAL captured market values', () => {
|
|
const rows = rowsFromSnapshot('mlb', [GRADE], [PROP], NOW);
|
|
expect(rows).toHaveLength(1);
|
|
const r = rows[0];
|
|
expect(r.user_id).toBeNull();
|
|
expect(r.player_key).toBe('aaron judge');
|
|
expect(r.line).toBe(0.5); // the locked book line
|
|
expect(r.locked_odds).toBe('-115'); // real odds at grade time
|
|
expect(r.book).toBe('draftkings');
|
|
expect(r.model_value).toBe(0.9); // MODEL output, distinct from line
|
|
expect(r.game_date).toBe('2026-07-10'); // 23:05Z = 19:05 ET same day
|
|
expect(r.game_id).toBe('mlb:2026-07-10:BOS@NYY');
|
|
});
|
|
|
|
// Session 59 — team/opponent addendum (migration 020).
|
|
test('captures team + opponent from the real feed (full-name MLB teams)', () => {
|
|
const grade = { ...GRADE, team: 'New York Yankees' };
|
|
const prop = { ...PROP, home_team: 'New York Yankees', away_team: 'Boston Red Sox' };
|
|
const [r] = rowsFromSnapshot('mlb', [grade], [prop], NOW);
|
|
expect(r.team).toBe('New York Yankees');
|
|
expect(r.opponent).toBe('Boston Red Sox');
|
|
});
|
|
|
|
test('opponent is NEVER guessed: team not in the game → opponent null', () => {
|
|
const grade = { ...GRADE, team: 'Tampa Bay Rays' };
|
|
const prop = { ...PROP, home_team: 'Milwaukee Brewers', away_team: 'Pittsburgh Pirates' };
|
|
const [r] = rowsFromSnapshot('mlb', [grade], [prop], NOW);
|
|
expect(r.team).toBe('Tampa Bay Rays');
|
|
expect(r.opponent).toBeNull();
|
|
});
|
|
|
|
test('no team from the stats resolve → both null (absent beats wrong)', () => {
|
|
const [r] = rowsFromSnapshot('mlb', [GRADE], [PROP], NOW);
|
|
expect(r.team).toBeNull();
|
|
expect(r.opponent).toBeNull();
|
|
});
|
|
|
|
test('refused reads and grade-less entries never become rows', () => {
|
|
const refused = { ...GRADE, grade: null, insufficient_data: true };
|
|
const gradeless = { ...GRADE, grade: undefined };
|
|
expect(rowsFromSnapshot('mlb', [refused, gradeless], [PROP], NOW)).toEqual([]);
|
|
});
|
|
|
|
test('a grade with no captured line is dropped — never a fabricated line', () => {
|
|
const noLine = { ...GRADE, line: undefined, gradedAt: { line: null, odds: null, timestamp: NOW } };
|
|
expect(rowsFromSnapshot('mlb', [noLine], [PROP], NOW)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('recordPipelineGrades — idempotent upsert', () => {
|
|
test('upserts with ignoreDuplicates on the dedupe constraint', async () => {
|
|
const sb = fakeSb();
|
|
const res = await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], { sb, now: () => NOW });
|
|
expect(res.written).toBe(1);
|
|
expect(sb._calls.upserts).toHaveLength(1);
|
|
const { opts } = sb._calls.upserts[0];
|
|
expect(opts.onConflict).toBe('user_id,player_key,stat,line,side,game_id');
|
|
expect(opts.ignoreDuplicates).toBe(true); // re-runs never overwrite the lock
|
|
});
|
|
|
|
test('no-ops without supabase env when no client injected', async () => {
|
|
const res = await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], {});
|
|
expect(res.skipped).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('computeClv — signed by side (Phase 1 amendment)', () => {
|
|
test('OVER: closing below the locked line = positive = beat', () => {
|
|
expect(computeClv('over', 1.5, 1.0)).toBe(0.5);
|
|
expect(clvResultOf(computeClv('over', 1.5, 1.0))).toBe('beat');
|
|
});
|
|
test('OVER: closing above the locked line = negative = faded', () => {
|
|
expect(computeClv('over', 1.5, 2.0)).toBe(-0.5);
|
|
expect(clvResultOf(computeClv('over', 1.5, 2.0))).toBe('faded');
|
|
});
|
|
test('UNDER: inverse signs', () => {
|
|
expect(computeClv('under', 1.5, 2.0)).toBe(0.5); // market rose toward the under
|
|
expect(clvResultOf(computeClv('under', 1.5, 2.0))).toBe('beat');
|
|
expect(computeClv('under', 1.5, 1.0)).toBe(-0.5);
|
|
expect(clvResultOf(computeClv('under', 1.5, 1.0))).toBe('faded');
|
|
});
|
|
test('unchanged line = flat; missing closing = null (absent, not zero)', () => {
|
|
expect(clvResultOf(computeClv('over', 1.5, 1.5))).toBe('flat');
|
|
expect(computeClv('over', 1.5, null)).toBeNull();
|
|
expect(clvResultOf(null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('settleLedger — outcome + CLV vs the real result', () => {
|
|
test('settles hit/miss/push from the game log and stamps CLV', async () => {
|
|
const sb = fakeSb();
|
|
// 1st select: open ids; 2nd: full rows.
|
|
sb._state.selectResults = [
|
|
[{ id: 'r1' }, { id: 'r2' }],
|
|
[
|
|
{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: 0.5, game_date: '2026-07-09' },
|
|
{ id: 'r2', player_name: 'Aaron Judge', stat: 'hits', line: 1.5, side: 'over', closing_line: 2.5, game_date: '2026-07-09' },
|
|
],
|
|
];
|
|
const getPlayerStats = async () => ({
|
|
found: true,
|
|
last10: [{ date: '2026-07-09', stat: { homeRuns: 1, hits: 1 } }],
|
|
});
|
|
const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10' });
|
|
expect(res.settled).toBe(2);
|
|
const byOutcome = sb._calls.updates.map((u) => u.values);
|
|
expect(byOutcome[0]).toMatchObject({ outcome: 'hit', actual_value: 1, clv: 0, clv_result: 'flat' });
|
|
// 1 hit vs o1.5 = miss; locked 1.5 closed 2.5 for an over = faded.
|
|
expect(byOutcome[1]).toMatchObject({ outcome: 'miss', actual_value: 1, clv: -1, clv_result: 'faded' });
|
|
});
|
|
|
|
// 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 → NOT voided (date may be wrong); attempts bump', async () => {
|
|
const sb = fakeSb();
|
|
sb._state.selectResults = [
|
|
[{ id: 'r1' }],
|
|
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }],
|
|
];
|
|
const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] });
|
|
const res = await ledger.settleLedger('mlb', {
|
|
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
|
getSchedule: async () => [{ status: 'Final' }],
|
|
});
|
|
expect(res.voided).toBe(0);
|
|
expect(res.settled).toBe(0);
|
|
expect(res.pending).toBe(1);
|
|
expect(sb._calls.updates[0].values.outcome).toBeUndefined();
|
|
});
|
|
|
|
test('no row + game NOT FINAL → stays pending, never voided', async () => {
|
|
const sb = fakeSb();
|
|
sb._state.selectResults = [
|
|
[{ id: 'r1' }],
|
|
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }],
|
|
];
|
|
const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] });
|
|
const res = await ledger.settleLedger('mlb', {
|
|
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
|
getSchedule: async () => [{ status: 'Suspended' }],
|
|
});
|
|
expect(res.pending).toBe(1);
|
|
expect(res.voided).toBe(0);
|
|
// only the attempt counter is touched — no outcome written
|
|
expect(sb._calls.updates[0].values).toMatchObject({ settle_attempts: 1 });
|
|
expect(sb._calls.updates[0].values.outcome).toBeUndefined();
|
|
});
|
|
|
|
test('undetermined state becomes UNRECOVERABLE at the retry cap', async () => {
|
|
const sb = fakeSb();
|
|
sb._state.selectResults = [
|
|
[{ id: 'r1' }],
|
|
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09', settle_attempts: 3 }],
|
|
];
|
|
const getPlayerStats = async () => ({ found: true, last10: [] });
|
|
const res = await ledger.settleLedger('mlb', {
|
|
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
|
getSchedule: async () => [{ status: 'Final' }], // player absent, unconfirmed
|
|
});
|
|
expect(res.unrecoverable).toBe(1);
|
|
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' });
|
|
});
|
|
});
|
|
|
|
describe('captureClosing — real feed values only', () => {
|
|
test('updates open rows from the current odds; unmatched rows untouched', async () => {
|
|
const sb = fakeSb();
|
|
sb._state.selectResults = [[
|
|
{ id: 'r1', player_key: 'aaron judge', stat: 'home_runs', side: 'over' },
|
|
{ id: 'r2', player_key: 'ghost player', stat: 'hits', side: 'over' },
|
|
]];
|
|
const res = await ledger.captureClosing('mlb', [PROP], { sb, gameDate: '2026-07-10' });
|
|
expect(res.updated).toBe(1); // only the matched prop
|
|
expect(sb._calls.updates[0].values).toEqual({ closing_line: 0.5, closing_odds: '-115' });
|
|
expect(sb._calls.updates[0].ids).toEqual(['r1']);
|
|
});
|
|
});
|
|
|
|
describe('getModelAggregate — never a % under min sample', () => {
|
|
// beat_close/CLV are suppressed by default (item 7 — CLV capture broken until
|
|
// C4). These tests exercise the CLV MATH, so enable the reliable flag; a
|
|
// separate test below locks the default-suppressed behavior.
|
|
beforeAll(() => { process.env.CLV_CAPTURE_RELIABLE = '1'; });
|
|
afterAll(() => { delete process.env.CLV_CAPTURE_RELIABLE; });
|
|
|
|
test('beat_close is SUPPRESSED by default until C4 (CLV capture broken)', async () => {
|
|
delete process.env.CLV_CAPTURE_RELIABLE; // default state
|
|
const sb = fakeSb();
|
|
sb._state.selectResults = [[
|
|
...Array.from({ length: 13 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
|
|
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded' })),
|
|
]];
|
|
sb._state.countResult = 0;
|
|
const agg = await ledger.getModelAggregate({ sb });
|
|
expect(agg.hit_pct).toBe(65); // hit rate still renders (it's real)
|
|
expect(agg.beat_close_pct).toBeNull(); // BEAT CLOSE hidden — measured-wrong
|
|
expect(agg.clv_distribution).toBeNull();
|
|
process.env.CLV_CAPTURE_RELIABLE = '1'; // restore for the rest of the block
|
|
});
|
|
|
|
test('below 20 settles → hit_pct/beat_close_pct null, counts real', async () => {
|
|
const sb = fakeSb();
|
|
sb._state.selectResults = [
|
|
Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
|
|
];
|
|
sb._state.countResult = 42;
|
|
const agg = await ledger.getModelAggregate({ sb });
|
|
expect(agg.settled).toBe(5);
|
|
expect(agg.hits).toBe(5);
|
|
expect(agg.hit_pct).toBeNull(); // n<20 — RECORD BUILDING
|
|
expect(agg.beat_close_pct).toBeNull();
|
|
expect(agg.pending).toBe(42);
|
|
});
|
|
|
|
test('at 20+ settles → both percentages render', async () => {
|
|
const sb = fakeSb();
|
|
const rows = [
|
|
...Array.from({ length: 13 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
|
|
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded' })),
|
|
];
|
|
sb._state.selectResults = [rows];
|
|
sb._state.countResult = 3;
|
|
const agg = await ledger.getModelAggregate({ sb });
|
|
expect(agg.settled).toBe(20);
|
|
expect(agg.hit_pct).toBe(65); // 13 / (13+7)
|
|
expect(agg.beat_close_pct).toBe(65); // 13 beat / 20 with clv
|
|
});
|
|
});
|
|
|
|
// Session 60 (night2/F, 5.5) — calibration by grade tier.
|
|
describe('getModelAggregate — per-tier calibration (n≥20 per tier)', () => {
|
|
test('a tier at 20+ settles gets a pct; a small tier stays building (null)', async () => {
|
|
const sb = fakeSb();
|
|
const rows = [
|
|
...Array.from({ length: 14 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })),
|
|
...Array.from({ length: 6 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })),
|
|
...Array.from({ length: 3 }, () => ({ outcome: 'hit', clv_result: null, grade: 'B' })),
|
|
];
|
|
sb._state.selectResults = [rows];
|
|
sb._state.countResult = 0;
|
|
const agg = await ledger.getModelAggregate({ sb });
|
|
expect(agg.by_tier.A.settled).toBe(20); // A + A- bucket together
|
|
expect(agg.by_tier.A.hit_pct).toBe(70); // 14/(14+6)
|
|
expect(agg.by_tier.B.settled).toBe(3);
|
|
expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building
|
|
});
|
|
|
|
test('A+ stands ALONE; A/A- do NOT fold into it (Addition 2 bucketing)', async () => {
|
|
const sb = fakeSb();
|
|
const rows = [
|
|
...Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A+' })),
|
|
...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })),
|
|
...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })),
|
|
];
|
|
sb._state.selectResults = [rows];
|
|
sb._state.countResult = 0;
|
|
const agg = await ledger.getModelAggregate({ sb });
|
|
// A+ is its own bucket — never merged into the first-letter A bucket.
|
|
expect(agg.by_tier['A+'].settled).toBe(5);
|
|
expect(agg.by_tier['A+'].hits).toBe(5);
|
|
// A + A- fold together by first letter (but NOT A+).
|
|
expect(agg.by_tier.A.settled).toBe(6);
|
|
expect(agg.by_tier.A.hits).toBe(4);
|
|
expect(agg.by_tier.A.misses).toBe(2);
|
|
});
|
|
});
|
|
|
|
// Session 61 — the odds index prefers a fully-priced book row (the null
|
|
// locked_odds rows from day one: first-seen betmgm SB unders had no juice
|
|
// while another book priced both sides).
|
|
describe('indexProps — prefers a book row with both sides priced', () => {
|
|
const { indexProps } = ledger.__internals;
|
|
test('a later both-sided row replaces a one-sided first row', () => {
|
|
const oneSided = { player: 'A Guy', stat_type: 'stolen_bases', line: 0.5, book: 'betmgm', over_odds: 120, under_odds: null };
|
|
const bothSided = { player: 'A Guy', stat_type: 'stolen_bases', line: 0.5, book: 'fanduel', over_odds: 130, under_odds: -170 };
|
|
const idx = indexProps([oneSided, bothSided]);
|
|
expect(idx['a guy|stolen_bases'].book).toBe('fanduel');
|
|
});
|
|
test('a both-sided first row is never displaced', () => {
|
|
const bothSided = { player: 'A Guy', stat_type: 'hits', line: 1.5, book: 'fanduel', over_odds: -110, under_odds: -110 };
|
|
const oneSided = { player: 'A Guy', stat_type: 'hits', line: 1.5, book: 'betmgm', over_odds: -115, under_odds: null };
|
|
const idx = indexProps([bothSided, oneSided]);
|
|
expect(idx['a guy|hits'].book).toBe('fanduel');
|
|
});
|
|
});
|
|
|
|
// S6 (A1 board) — CLV distribution on the model aggregate. The n>=20 gate is
|
|
// centralized HERE (getModelAggregate) — consumers never re-derive it.
|
|
describe('getModelAggregate — clv_distribution (n>=20 gate lives in the service)', () => {
|
|
const { clvBucketIndex, CLV_BUCKETS } = ledger.__internals;
|
|
// CLV suppressed by default (item 7); enable to test the distribution math.
|
|
beforeAll(() => { process.env.CLV_CAPTURE_RELIABLE = '1'; });
|
|
afterAll(() => { delete process.env.CLV_CAPTURE_RELIABLE; });
|
|
|
|
test('below 20 settles → clv_distribution is null (never a small-sample chart)', async () => {
|
|
const sb = fakeSb();
|
|
sb._state.selectResults = [
|
|
Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 0.5 })),
|
|
];
|
|
const agg = await ledger.getModelAggregate({ sb });
|
|
expect(agg.clv_distribution).toBeNull();
|
|
});
|
|
|
|
test('at 20+ settles → seven ordered buckets, counts bucketed by signed clv', async () => {
|
|
const sb = fakeSb();
|
|
const rows = [
|
|
...Array.from({ length: 8 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 0.5 })),
|
|
...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 1.5 })),
|
|
...Array.from({ length: 5 }, () => ({ outcome: 'miss', clv_result: 'faded', clv: -0.5 })),
|
|
...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: 'flat', clv: 0 })),
|
|
{ outcome: 'push', clv_result: 'faded', clv: -3 }, // outlier clamps into the edge bucket
|
|
];
|
|
sb._state.selectResults = [rows];
|
|
const agg = await ledger.getModelAggregate({ sb });
|
|
const dist = agg.clv_distribution;
|
|
expect(dist).toHaveLength(7);
|
|
expect(dist.map((b) => b.label)).toEqual(CLV_BUCKETS.map((b) => b.label));
|
|
expect(dist[0]).toMatchObject({ side: 'faded', count: 1 }); // [-2,-1) — clamped -3
|
|
expect(dist[2]).toMatchObject({ side: 'faded', count: 5 }); // [-.5,0) — -0.5 closed on the left
|
|
expect(dist[1]).toMatchObject({ side: 'faded', count: 0 }); // [-1,-.5) — empty
|
|
expect(dist[3]).toMatchObject({ side: 'flat', count: 2 }); // exactly 0
|
|
expect(dist[4]).toMatchObject({ side: 'beat', count: 8 }); // (0,.5]
|
|
expect(dist[6]).toMatchObject({ side: 'beat', count: 4 }); // (1,2] — 1.5
|
|
});
|
|
|
|
test('rows without a settled clv value never fabricate a bucket', async () => {
|
|
const sb = fakeSb();
|
|
sb._state.selectResults = [
|
|
Array.from({ length: 20 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: null })),
|
|
];
|
|
const agg = await ledger.getModelAggregate({ sb });
|
|
expect(agg.clv_distribution).toBeNull(); // no real clv values → nothing
|
|
});
|
|
|
|
test('clvBucketIndex edges: boundaries land per the spec intervals', () => {
|
|
expect(clvBucketIndex(0)).toBe(3);
|
|
expect(clvBucketIndex(0.5)).toBe(4); // (0,.5] closed on the right
|
|
expect(clvBucketIndex(0.51)).toBe(5);
|
|
expect(clvBucketIndex(1)).toBe(5); // (.5,1]
|
|
expect(clvBucketIndex(1.01)).toBe(6);
|
|
expect(clvBucketIndex(-0.5)).toBe(2); // [-.5,0) closed on the left
|
|
expect(clvBucketIndex(-0.51)).toBe(1); // [-1,-.5)
|
|
expect(clvBucketIndex(-1)).toBe(1); // [-1,-.5) closed on the left
|
|
expect(clvBucketIndex(-1.01)).toBe(0); // [-2,-1)
|
|
expect(clvBucketIndex(-9)).toBe(0); // clamp
|
|
expect(clvBucketIndex(9)).toBe(6); // clamp
|
|
expect(clvBucketIndex(null)).toBe(-1); // absent beats wrong
|
|
});
|
|
});
|