162 lines
5.6 KiB
JavaScript
162 lines
5.6 KiB
JavaScript
const mockState = {
|
|
resolutionCount: 100,
|
|
weightRows: [], // engine1_weights rows
|
|
inserts: [],
|
|
};
|
|
|
|
jest.mock('../../src/utils/supabase', () => ({
|
|
getSupabaseServiceClient: () => ({
|
|
from(table) {
|
|
const ctx = { table, filters: {} };
|
|
const proxy = {
|
|
select(_cols, opts) {
|
|
ctx.head = !!opts?.head;
|
|
ctx.countMode = opts?.count;
|
|
return proxy;
|
|
},
|
|
eq(col, val) { ctx.filters[col] = val; return proxy; },
|
|
order() { return proxy; },
|
|
limit() { return proxy; },
|
|
maybeSingle() {
|
|
const match = mockState.weightRows.find(
|
|
(r) =>
|
|
r.sport === ctx.filters.sport
|
|
&& r.stat_type === ctx.filters.stat_type
|
|
&& r.factor_name === ctx.filters.factor_name
|
|
&& r.version === ctx.filters.version
|
|
);
|
|
return Promise.resolve({ data: match || null, error: null });
|
|
},
|
|
insert(row) {
|
|
mockState.inserts.push(row);
|
|
mockState.weightRows.push(row);
|
|
return Promise.resolve({ error: null });
|
|
},
|
|
then(resolve) {
|
|
if (ctx.table === 'resolution_results' && ctx.countMode === 'exact') {
|
|
return resolve({ count: mockState.resolutionCount, error: null });
|
|
}
|
|
// List of engine1_weights matching filters.
|
|
const matches = mockState.weightRows.filter((r) => {
|
|
for (const [k, v] of Object.entries(ctx.filters)) {
|
|
if (r[k] !== v) return false;
|
|
}
|
|
return true;
|
|
});
|
|
matches.sort((a, b) => b.version - a.version);
|
|
return resolve({ data: matches, error: null });
|
|
},
|
|
};
|
|
return proxy;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
const wa = require('../../src/services/intelligence/weightAdjuster');
|
|
|
|
beforeEach(() => {
|
|
mockState.resolutionCount = 100;
|
|
mockState.weightRows = [];
|
|
mockState.inserts.length = 0;
|
|
});
|
|
|
|
describe('weightAdjuster — skip conditions', () => {
|
|
test('skips when sample too thin', async () => {
|
|
mockState.resolutionCount = 5;
|
|
const r = await wa.adjustWeights({
|
|
sport: 'nba', stat_type: 'points', grade: 'A', result: 'hit',
|
|
factors: ['l5_avg'], grade_id: 'g',
|
|
});
|
|
expect(r.skipped).toBe(true);
|
|
expect(r.reason).toBe('thin_sample');
|
|
});
|
|
|
|
test('skips on push / void', async () => {
|
|
const r = await wa.adjustWeights({
|
|
sport: 'nba', stat_type: 'points', grade: 'A', result: 'push',
|
|
factors: ['l5_avg'], grade_id: 'g',
|
|
});
|
|
expect(r.skipped).toBe(true);
|
|
expect(r.reason).toBe('non_decisive_result');
|
|
});
|
|
|
|
test('skips on incomplete input', async () => {
|
|
const r = await wa.adjustWeights({ sport: 'nba', grade: 'A', result: 'hit', factors: [] });
|
|
expect(r.skipped).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('weightAdjuster — adjustments', () => {
|
|
test('A+ hit nudges factor up by at most 0.5%', async () => {
|
|
const r = await wa.adjustWeights({
|
|
sport: 'nba', stat_type: 'points', grade: 'A+', result: 'hit',
|
|
factors: ['l5_hot_vs_line'], grade_id: 'g1',
|
|
});
|
|
expect(r.skipped).toBe(false);
|
|
const adj = r.adjustments[0];
|
|
expect(adj.previous).toBe(1.0);
|
|
expect(adj.next).toBeGreaterThan(1.0);
|
|
// confidence of A+ = 1.0, LR = 0.005 → multiplier = 1.005 → next = 1.005
|
|
expect(adj.next).toBeCloseTo(1.005, 5);
|
|
});
|
|
|
|
test('A+ miss nudges factor down by at most 0.5%', async () => {
|
|
const r = await wa.adjustWeights({
|
|
sport: 'nba', stat_type: 'points', grade: 'A+', result: 'miss',
|
|
factors: ['l5_hot_vs_line'], grade_id: 'g2',
|
|
});
|
|
expect(r.adjustments[0].next).toBeCloseTo(0.995, 5);
|
|
});
|
|
|
|
test('low-confidence grade produces smaller nudge', async () => {
|
|
const high = await wa.adjustWeights({
|
|
sport: 'nba', stat_type: 'points', grade: 'A+', result: 'hit',
|
|
factors: ['x'], grade_id: 'h',
|
|
});
|
|
const low = await wa.adjustWeights({
|
|
sport: 'nba', stat_type: 'points', grade: 'C', result: 'hit',
|
|
factors: ['y'], grade_id: 'l',
|
|
});
|
|
expect(Math.abs(high.adjustments[0].next - 1.0))
|
|
.toBeGreaterThan(Math.abs(low.adjustments[0].next - 1.0));
|
|
});
|
|
|
|
test('weights clamp at MIN_WEIGHT and MAX_WEIGHT', () => {
|
|
const c = wa.__internals.clamp;
|
|
expect(c(0.05)).toBe(wa.MIN_WEIGHT);
|
|
expect(c(99)).toBe(wa.MAX_WEIGHT);
|
|
expect(c(2.5)).toBe(2.5);
|
|
});
|
|
|
|
test('repeated adjustments stack into incrementing versions', async () => {
|
|
await wa.adjustWeights({
|
|
sport: 'nba', stat_type: 'points', grade: 'A', result: 'hit',
|
|
factors: ['l5_hot_vs_line'], grade_id: 'g1',
|
|
});
|
|
await wa.adjustWeights({
|
|
sport: 'nba', stat_type: 'points', grade: 'A', result: 'hit',
|
|
factors: ['l5_hot_vs_line'], grade_id: 'g2',
|
|
});
|
|
const history = await wa.getWeightHistory('nba', 'points', 'l5_hot_vs_line', 10);
|
|
expect(history.length).toBe(2);
|
|
expect(history[0].version).toBe(2);
|
|
expect(history[1].version).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('weightAdjuster — rollback', () => {
|
|
test('rollback inserts a new row whose weight equals the target version', async () => {
|
|
// Seed three versions.
|
|
mockState.weightRows.push(
|
|
{ sport: 'nba', stat_type: 'points', factor_name: 'f', weight: 1.0, version: 1 },
|
|
{ sport: 'nba', stat_type: 'points', factor_name: 'f', weight: 1.1, version: 2 },
|
|
{ sport: 'nba', stat_type: 'points', factor_name: 'f', weight: 1.2, version: 3 },
|
|
);
|
|
const ok = await wa.rollbackToVersion('nba', 'points', 'f', 1);
|
|
expect(ok).toBe(true);
|
|
const history = await wa.getWeightHistory('nba', 'points', 'f', 10);
|
|
expect(history[0].weight).toBe(1.0);
|
|
expect(history[0].version).toBe(4);
|
|
});
|
|
});
|