89a2977f57
Kev's call: the 30D accuracy surfaces must read TRUTH, not a cache that can't be filtered. My earlier degraded-row exclusion only touched getModelAggregate (Postgres); the public buckets/badge still read outcomeService (Redis outcome log), which counts degraded projection-0 outcomes and has no field to filter on. - /api/accuracy (AccuracyBadge) + /api/ledger/accuracy (buckets/ModelRecord) now source from the clean Postgres ledger aggregate via new ledgerService.getAccuracyView + accuracyBucketsFromAgg (model_value > 0 excludes degraded rows). Same response shapes → no frontend change. Redis outcome log is now read by nothing public; it can age out or be rebuilt. - BEAT CLOSE is a MEASURED-WRONG ZERO: captureClosing re-records the locked line as the "closing" line, so clv is flat on the whole sample and beat_close reads 0% (comparing a number to itself). Full write-up: specs/audit-data/ clv-capture-broken.md (the fix belongs to C4). Until then, beat_close_pct + clv_distribution are SUPPRESSED at the source (getModelAggregate, gated by clvCaptureReliable() / CLV_CAPTURE_RELIABLE=1). Every public surface already renders BEAT CLOSE only when non-null, so they all hide it now — no wrong zero anywhere. HIT RATE (real) is unaffected. Suite 271/3261 green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
95 lines
3.7 KiB
JavaScript
95 lines
3.7 KiB
JavaScript
'use strict';
|
|
|
|
// Truth-Everywhere Part 2 (item 7) — the public accuracy endpoints now read the
|
|
// CLEAN Postgres ledger aggregate (getModelAggregate / getAccuracyView), NOT the
|
|
// Redis outcome log. We partial-mock ledgerService so these run offline.
|
|
|
|
const request = require('supertest');
|
|
|
|
let mockStore = {};
|
|
jest.mock('../../src/utils/redis', () => ({
|
|
getRedisClient: () => ({}),
|
|
cacheGet: async (k) => (k in mockStore ? mockStore[k] : null),
|
|
cacheSet: async (k, v) => { mockStore[k] = v; return true; },
|
|
cacheDel: async () => true,
|
|
isDegraded: () => false,
|
|
}));
|
|
|
|
jest.mock('../../src/services/ledgerService', () => {
|
|
const actual = jest.requireActual('../../src/services/ledgerService');
|
|
return {
|
|
...actual,
|
|
getAccuracyView: jest.fn(),
|
|
getModelAggregate: jest.fn(),
|
|
accuracyBucketsFromAgg: actual.accuracyBucketsFromAgg, // keep the real bucketer
|
|
};
|
|
});
|
|
|
|
const ledgerService = require('../../src/services/ledgerService');
|
|
const app = require('../../src/app');
|
|
|
|
const EMPTY_AGG = {
|
|
window_days: 30, min_sample: 20, settled: 0, hits: 0, misses: 0, pushes: 0,
|
|
hit_pct: null, beat_close_pct: null, clv_distribution: null, by_tier: {}, pending: 0,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockStore = {};
|
|
ledgerService.getAccuracyView.mockReset();
|
|
ledgerService.getModelAggregate.mockReset();
|
|
});
|
|
|
|
describe('GET /api/accuracy', () => {
|
|
test('cold (no data) → valid empty-safe shape from the ledger view', async () => {
|
|
ledgerService.getAccuracyView.mockResolvedValue({
|
|
overall: { sport: 'overall', window_days: 30, sample: 0, min_sample: 20,
|
|
overall: { hits: 0, misses: 0, pushes: 0, total: 0, pct: null }, byGrade: {} },
|
|
sports: {}, min_sample: 20, updated_at: null,
|
|
});
|
|
const res = await request(app).get('/api/accuracy');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toHaveProperty('overall');
|
|
expect(res.body).toHaveProperty('sports');
|
|
expect(res.body.min_sample).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('returns the clean ledger record when present', async () => {
|
|
ledgerService.getAccuracyView.mockResolvedValue({
|
|
overall: { sport: 'overall', window_days: 30, sample: 20, min_sample: 20,
|
|
overall: { hits: 14, misses: 6, pushes: 0, total: 20, pct: 70 },
|
|
byGrade: { A: { hits: 8, misses: 2, pushes: 0, total: 10, pct: 80 } } },
|
|
sports: { mlb: { sport: 'mlb', overall: { hits: 14, misses: 6, pushes: 0, total: 20, pct: 70 }, byGrade: {} } },
|
|
min_sample: 20, updated_at: null,
|
|
});
|
|
const res = await request(app).get('/api/accuracy');
|
|
expect(res.body.overall.overall.pct).toBe(70);
|
|
expect(res.body.sports.mlb).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('GET /api/ledger/accuracy', () => {
|
|
test('returns grade-tier buckets from the clean ledger aggregate', async () => {
|
|
ledgerService.getModelAggregate.mockResolvedValue({
|
|
...EMPTY_AGG, settled: 30, hits: 20, misses: 10, hit_pct: 67,
|
|
by_tier: {
|
|
'A+': { settled: 3, hits: 3, misses: 0, pushes: 0, hit_pct: null },
|
|
'A': { settled: 22, hits: 15, misses: 7, pushes: 0, hit_pct: 68 },
|
|
'B': { settled: 5, hits: 2, misses: 3, pushes: 0, hit_pct: null },
|
|
},
|
|
});
|
|
const res = await request(app).get('/api/ledger/accuracy');
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body.buckets)).toBe(true);
|
|
const grades = res.body.buckets.map((b) => b.grade);
|
|
expect(grades).toContain('A'); // A+ folds into A in the public strip
|
|
expect(res.body.overall.pct).toBe(67);
|
|
});
|
|
|
|
test('empty aggregate → empty buckets, never 500', async () => {
|
|
ledgerService.getModelAggregate.mockResolvedValue({ ...EMPTY_AGG });
|
|
const res = await request(app).get('/api/ledger/accuracy');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.buckets).toEqual([]);
|
|
});
|
|
});
|