diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index d0d63de..b5f5aa9 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -445,10 +445,17 @@ async function getModelAggregate(opts = {}) { const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10); let settledQ = sb.from('ledger_entries') - .select('outcome, clv_result, clv, player_key, grade'); + .select('outcome, clv_result, clv, player_key, grade, model_value'); settledQ = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null); settledQ = settledQ .not('outcome', 'is', null) + // 2026-07 — a grade with a non-positive model_value had NO real projection + // (the pre-fix degradation). Those locks are kept in the append-only ledger + // but must not count toward the public model record — their hit/miss is + // noise, not model skill. `.gt` also excludes NULL model_value. Post-fix no + // such row can be written (projection<=0 now refuses), so this only filters + // the historical blast radius. + .gt('model_value', 0) .gte('game_date', since) .limit(AGG_FETCH_LIMIT); if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase()); @@ -460,7 +467,7 @@ async function getModelAggregate(opts = {}) { let pendingQ = sb.from('ledger_entries') .select('id', { count: 'exact', head: true }); pendingQ = opts.userId ? pendingQ.eq('user_id', opts.userId) : pendingQ.is('user_id', null); - pendingQ = pendingQ.is('outcome', null); + pendingQ = pendingQ.is('outcome', null).gt('model_value', 0); // same real-projection filter if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase()); if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey); if (opts.team) pendingQ = pendingQ.eq('team', opts.team); diff --git a/tests/integration/ledgerRoutes.test.js b/tests/integration/ledgerRoutes.test.js index 8f0a91a..d242c24 100644 --- a/tests/integration/ledgerRoutes.test.js +++ b/tests/integration/ledgerRoutes.test.js @@ -24,6 +24,7 @@ function mockChain() { is(col, val) { b._filters.push(['is', col, val]); return b; }, not(col, op, val) { b._filters.push(['not', col, op, val]); return b; }, gte(col, val) { b._filters.push(['gte', col, val]); return b; }, + gt(col, val) { b._filters.push(['gt', col, val]); return b; }, ilike(col, val) { b._filters.push(['ilike', col, val]); return b; }, order() { return b; }, limit() { diff --git a/tests/integration/profilesRoutes.test.js b/tests/integration/profilesRoutes.test.js index 5aeb730..a2dbc69 100644 --- a/tests/integration/profilesRoutes.test.js +++ b/tests/integration/profilesRoutes.test.js @@ -33,6 +33,7 @@ function mockChain(table) { b.is = rec('is'); b.not = rec('not'); b.gte = rec('gte'); + b.gt = rec('gt'); b.order = () => b; b.maybeSingle = () => { mockState.filters.push([table, b._filters]); diff --git a/tests/unit/houseProfile.test.js b/tests/unit/houseProfile.test.js index 751cf13..4058d8a 100644 --- a/tests/unit/houseProfile.test.js +++ b/tests/unit/houseProfile.test.js @@ -32,6 +32,7 @@ function mockChain(table) { b.is = rec('is'); b.not = rec('not'); b.gte = rec('gte'); + b.gt = rec('gt'); b.order = () => b; b.maybeSingle = () => { mockState.filters.push([table, b._filters]); diff --git a/tests/unit/ledgerAggregateScope.test.js b/tests/unit/ledgerAggregateScope.test.js index 19d791a..4bd0cfb 100644 --- a/tests/unit/ledgerAggregateScope.test.js +++ b/tests/unit/ledgerAggregateScope.test.js @@ -15,6 +15,7 @@ function makeSb(captured, rows) { b.is = rec('is'); b.not = rec('not'); b.gte = rec('gte'); + b.gt = rec('gt'); b.order = () => b; b.limit = () => { captured.push(b._filters); return Promise.resolve({ data: rows, error: null, count: 0 }); }; b.then = (resolve, reject) => { @@ -37,6 +38,12 @@ describe('getModelAggregate scoping', () => { } expect(agg.hit_pct).toBeNull(); expect(agg.min_sample).toBe(ledgerService.MIN_AGG_SAMPLE); + // 2026-07 — projection<=0 grades (the degradation blast radius) are excluded + // from the public record on BOTH queries (kept in the append-only ledger, + // just never counted). `.gt('model_value', 0)` also drops NULL model_value. + for (const filters of captured) { + expect(filters).toContainEqual(['gt', 'model_value', 0]); + } }); test('userId scopes BOTH queries to that user and never touches the public scope', async () => { diff --git a/tests/unit/ledgerService.test.js b/tests/unit/ledgerService.test.js index 672daf7..8570e8f 100644 --- a/tests/unit/ledgerService.test.js +++ b/tests/unit/ledgerService.test.js @@ -24,6 +24,7 @@ function fakeSb() { 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 }); diff --git a/tests/unit/nbaSettlement.test.js b/tests/unit/nbaSettlement.test.js index 2a39c54..cbd8966 100644 --- a/tests/unit/nbaSettlement.test.js +++ b/tests/unit/nbaSettlement.test.js @@ -154,7 +154,7 @@ describe('ledgerService.settleLedger — WNBA settles vs the ESPN game log', () upsert() { return Promise.resolve({ error: null }); }, update(v) { b._update = v; return b; }, select() { return b; }, eq() { return b; }, is() { return b; }, - not() { return b; }, lt() { return b; }, gte() { return b; }, order() { return b; }, + not() { return b; }, lt() { return b; }, gte() { return b; }, gt() { return b; }, order() { return b; }, in(col, ids) { if (b._update) { calls.updates.push({ values: b._update, ids }); return Promise.resolve({ error: null }); } return terminal();