Files
vyndr/tests/unit/intelligenceEngine.test.js
T
builtbykev 86d123945c Rank on p_win: challenger instrument + retire edge from decisions
MEASURED BASIS (n=200 settled MLB rows): corr(p_win, outcome) = +0.26;
corr(edge, outcome) = -0.010 incumbent ruler / -0.022 consensus ruler.
Subtracting the market destroys the signal under BOTH rulers, so a
quantity that does not predict must not rank, gate or decide.

CHALLENGER-FIRST -- live ordering is byte-identical. rankGrades (the
incumbent, grade-first with edge as its 4th key) is untouched and tested
as untouched.

NEW: rankByForecast -- takeable-gated p_win -> grade -> confidence -> stable
order, with NO edge term anywhere. p_win LEADS and the letter follows,
deliberately: the letter measured r ~ 0.005 and is inverted (B 52.4% <
C 56.9%) while p_win measures +0.26, so leading with the letter would sort
by the weaker signal and use the stronger one only to break ties.

Recorded in the code: isotonic calibration is a MONOTONE transform, so
ranking on raw vs calibrated p_win gives the SAME ORDER. Calibration
matters when p_win is displayed or thresholded; it cannot change a
ranking. Nothing here needs the calibrated value.

rankingDelta + GET /api/internal/ranking-delta measure how far the board
would move before any flip. The endpoint reports p_win coverage alongside
the delta -- if p_win is absent the challenger degrades to grade order and
the delta UNDERSTATES, which is worth saying rather than reporting a clean
zero.

forecast_rank is stamped on snapshot grades BEFORE stripModelPrice, so
every tier gets the correct order without the paid values (the
topGradedService precedent -- an ordinal can travel where the magnitude
cannot). Additive only: nothing sorts by it yet.

RETIRED AS DECISIONS (not rankings, so done now):
- altLineScanner.compareToBookImplied no longer returns value_detected:
  edge > 0. Edge is still COMPUTED and returned -- losing the record would
  be worse than mis-using it -- but the verdict is an honest null with
  value_basis: 'retired:edge_does_not_predict'.
- scanAltLines no longer filters to edge>0 or calls the survivor "optimal".
  The whole ladder is returned ranked and labelled
  'price_gap_diagnostic_unvalidated'. The module has ZERO callers (verified)
  -- unwired like mlbGrader.js, left in place and made honest.

An honest asymmetry recorded there: ranking props AGAINST EACH OTHER must
not use edge, but choosing between RUNGS OF THE SAME PROP is inherently
price-relative -- ranking rungs by model probability alone would always
pick the lowest line, since P(over 0.5) > P(over 2.5) by construction. So
the gap stays the rung key, explicitly labelled unvalidated.

Two superseded tests updated to stronger properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 01:24:55 -04:00

315 lines
13 KiB
JavaScript

const { SIMILARITY_WEIGHTS, calculateSimilarityScore, findSimilarGames, getPosteriorDistribution } = require('../../src/services/similarityEngine');
const { detectChangepoints, checkMultiSignalEvolution } = require('../../src/services/evolutionEngine');
const { detectDiscrepancy, detectSteamMove, getReliabilityScore } = require('../../src/services/lineDiscrepancyDetector');
const { scanAltLines, calculateModelProbability, compareToBookImplied, normalCDF: altNormalCDF } = require('../../src/services/altLineScanner');
const { DISTRIBUTION_SHAPES, getDistributionShape, calculateProbability, normalCDF, poissonCDF, negativeBinomialCDF } = require('../../src/services/bayesianEngine');
const { walkForwardValidate, calculateCLV, checkDrift, applyLearningRateCap } = require('../../src/services/modelTrainer');
const { phiCoefficient, hasMinimumObservations, calculateJuiceAdjustedEV } = require('../../src/services/correlationMath');
// Mock axios for evolution engine tests
jest.mock('axios', () => ({
post: jest.fn(),
}));
const axios = require('axios');
describe('Intelligence Engine', () => {
// --- Similarity Engine ---
describe('Similarity Engine', () => {
test('similarity score returns value between 0 and 1', () => {
const gameA = { functional_role_match: 0.8, pace: 100, rest_days: 2 };
const gameB = { functional_role_match: 0.7, pace: 98, rest_days: 1 };
const score = calculateSimilarityScore(gameA, gameB);
expect(score).toBeGreaterThanOrEqual(0);
expect(score).toBeLessThanOrEqual(1);
});
test('similarity weights sum to 1.0', () => {
const sum = Object.values(SIMILARITY_WEIGHTS).reduce((s, v) => s + v, 0);
expect(Math.round(sum * 100) / 100).toBe(1.0);
});
test('identical games return score of 1', () => {
const game = { functional_role_match: 0.8, pace: 100, rest_days: 2, opponent_defensive_rating: 110 };
const score = calculateSimilarityScore(game, game);
expect(score).toBe(1);
});
test('findSimilarGames returns LOW confidence when below minInstances', () => {
const target = { pace: 100 };
const historical = Array.from({ length: 5 }, (_, i) => ({ pace: 95 + i, statValue: 20 + i }));
const result = findSimilarGames(target, historical, 15);
expect(result.confidence).toBe('LOW');
expect(result.usedSeasonAvg).toBe(true);
});
test('findSimilarGames returns HIGH confidence with enough instances', () => {
const target = { pace: 100 };
const historical = Array.from({ length: 30 }, (_, i) => ({ pace: 95 + (i % 10), statValue: 20 + i }));
const result = findSimilarGames(target, historical, 15);
expect(result.confidence).toBe('HIGH');
expect(result.usedSeasonAvg).toBe(false);
});
test('getPosteriorDistribution returns correct structure', () => {
const games = Array.from({ length: 20 }, (_, i) => ({ statValue: 20 + Math.random() * 10 }));
const dist = getPosteriorDistribution(games);
expect(dist).toHaveProperty('mean');
expect(dist).toHaveProperty('stddev');
expect(dist).toHaveProperty('ci_low');
expect(dist).toHaveProperty('ci_high');
expect(dist).toHaveProperty('n');
expect(dist.n).toBe(20);
expect(dist.ci_low).toBeLessThan(dist.ci_high);
});
});
// --- Evolution Engine ---
describe('Evolution Engine', () => {
test('graceful degradation on HTTP failure', async () => {
axios.post.mockRejectedValue(new Error('Connection refused'));
const result = await detectChangepoints('player1', 'usage_rate', [0.2, 0.3], ['2026-01-01', '2026-01-02']);
expect(result.evolution_detected).toBe(false);
expect(result.error).toBe('Connection refused');
});
test('graceful degradation on timeout', async () => {
const timeoutError = new Error('timeout');
timeoutError.code = 'ECONNABORTED';
axios.post.mockRejectedValue(timeoutError);
const result = await detectChangepoints('player1', 'usage_rate', [0.2, 0.3], ['2026-01-01', '2026-01-02']);
expect(result.evolution_detected).toBe(false);
expect(result.error).toBe('timeout');
});
});
// --- Line Discrepancy Detector ---
describe('Line Discrepancy Detector', () => {
test('detects discrepancy when gap > 0.5', () => {
const lines = [
{ book: 'pinnacle', line: 24.5 },
{ book: 'circa', line: 24.5 },
{ book: 'draftkings', line: 25.5 },
{ book: 'fanduel', line: 25.5 },
{ book: 'betmgm', line: 25.5 },
];
const result = detectDiscrepancy(lines);
expect(result.discrepancy).toBe(true);
expect(result.gap).toBe(1);
});
test('no discrepancy when gap <= 0.5', () => {
const lines = [
{ book: 'pinnacle', line: 25.0 },
{ book: 'draftkings', line: 25.5 },
{ book: 'fanduel', line: 25.0 },
];
const result = detectDiscrepancy(lines);
expect(result.discrepancy).toBe(false);
});
test('detects steam move with 3+ books moving 0.5+ in 10 min', () => {
const now = new Date();
const movements = [
{ book: 'draftkings', line: 0.5, timestamp: now.toISOString() },
{ book: 'fanduel', line: 0.5, timestamp: new Date(now.getTime() + 60000).toISOString() },
{ book: 'betmgm', line: 0.5, timestamp: new Date(now.getTime() + 120000).toISOString() },
{ book: 'caesars', line: 0.5, timestamp: new Date(now.getTime() + 180000).toISOString() },
];
const result = detectSteamMove(movements);
expect(result.steam_move).toBe(true);
expect(result.books_moved).toBeGreaterThanOrEqual(3);
});
test('no steam move with insufficient books', () => {
const now = new Date();
const movements = [
{ book: 'draftkings', line: 0.5, timestamp: now.toISOString() },
{ book: 'fanduel', line: 0.5, timestamp: new Date(now.getTime() + 60000).toISOString() },
];
const result = detectSteamMove(movements);
expect(result.steam_move).toBe(false);
});
test('reliability score returns valid range', () => {
const score = getReliabilityScore('points', 'nba');
expect(score).toBeGreaterThan(0);
expect(score).toBeLessThanOrEqual(1);
});
});
// --- Alt Line Scanner ---
describe('Alt Line Scanner', () => {
test('calculates model probability correctly', () => {
// Mean 25, stddev 5, line 25 => P(over) should be ~0.5
const prob = calculateModelProbability(25, 5, 25, 'over');
expect(prob).toBeCloseTo(0.5, 1);
});
// SUPERSEDED 2026-08-01. This asserted `value_detected === true` from a
// positive edge. Edge does not predict outcomes (n=200 settled MLB rows:
// corr -0.010 incumbent ruler / -0.022 consensus, vs corr(p_win) = +0.26),
// so it must not decide anything. The stronger property: edge is still
// COMPUTED and returned as a diagnostic (losing the record would be worse
// than mis-using it), while the verdict is an honest null with a reason.
test('compareToBookImplied returns edge as a DIAGNOSTIC and refuses a verdict', () => {
const result = compareToBookImplied(0.60, -110);
expect(result.model_prob).toBe(0.6);
expect(result.book_implied).toBeCloseTo(0.524, 2);
expect(result.edge).toBeGreaterThan(0); // still recorded
expect(result.value_detected).toBeNull(); // never a boolean verdict
expect(result.value_basis).toBe('retired:edge_does_not_predict');
});
// SUPERSEDED 2026-08-01: 'optimal' was a quality claim edge cannot support,
// and filtering to edge>0 hid rungs. The ladder is now returned whole,
// ranked, and labelled as an unvalidated price diagnostic.
test('scanAltLines returns the whole ladder ranked, labelled unvalidated', () => {
const prop = { projected_mean: 25, projected_stddev: 5, direction: 'over' };
const odds = [
{ line: 22.5, odds: -130, book: 'draftkings' },
{ line: 24.5, odds: -110, book: 'draftkings' },
{ line: 27.5, odds: +120, book: 'draftkings' },
];
const result = scanAltLines(prop, odds);
expect(result).not.toBeNull();
expect(result.top_by_price_gap).toBeDefined();
expect(result.ranking_basis).toBe('price_gap_diagnostic_unvalidated');
// every rung survives — a negative gap is an observation, not a reason to hide
expect(result.ranked_lines).toHaveLength(odds.length);
expect(result.ranked_lines[0].edge).toBeGreaterThanOrEqual(result.ranked_lines[1].edge);
// no boolean verdict anywhere in the payload
expect(result.ranked_lines.every((r) => r.value_detected === undefined)).toBe(true);
});
});
// --- Bayesian Engine ---
describe('Bayesian Engine', () => {
test('normalCDF returns ~0.5 at the mean', () => {
const result = normalCDF(10, 10, 3);
expect(result).toBeCloseTo(0.5, 2);
});
test('normalCDF returns known values', () => {
// P(X <= 1) for N(0,1) should be ~0.8413
const result = normalCDF(1, 0, 1);
expect(result).toBeCloseTo(0.8413, 2);
});
test('poissonCDF correctness', () => {
// P(X <= 2) for Poisson(1) = e^-1 * (1 + 1 + 0.5) = 0.9197
const result = poissonCDF(2, 1);
expect(result).toBeCloseTo(0.9197, 2);
});
test('distribution shape mapping returns correct shapes', () => {
expect(getDistributionShape('points')).toBe('normal');
expect(getDistributionShape('walks')).toBe('poisson');
expect(getDistributionShape('home_runs')).toBe('negative_binomial');
expect(getDistributionShape('pitcher_strikeouts')).toBe('bimodal_mixture');
expect(getDistributionShape('unknown_stat')).toBe('normal');
});
test('calculateProbability works for poisson over', () => {
const prob = calculateProbability('poisson', { lambda: 5 }, 4, 'over');
// P(X > 4) for Poisson(5)
expect(prob).toBeGreaterThan(0.4);
expect(prob).toBeLessThan(0.8);
});
});
// --- Model Trainer ---
describe('Model Trainer', () => {
test('walkForwardValidate returns accuracy metrics', () => {
const predictions = [
{ predicted: 25, timestamp: '2026-01-01' },
{ predicted: 22, timestamp: '2026-01-02' },
{ predicted: 30, timestamp: '2026-01-03' },
];
const actuals = [
{ actual: 24, timestamp: '2026-01-01' },
{ actual: 23, timestamp: '2026-01-02' },
{ actual: 28, timestamp: '2026-01-03' },
];
const result = walkForwardValidate(predictions, actuals);
expect(result).toHaveProperty('accuracy');
expect(result).toHaveProperty('mae');
expect(result).toHaveProperty('rmse');
expect(result.n).toBe(3);
});
test('CLV calculation returns correct structure', () => {
const clv = calculateCLV(24.5, 25.0, 25.5);
expect(clv.clv_at_prediction).toBe(1.0);
expect(clv.clv_at_24hr).toBe(0.5);
expect(clv.clv_at_tip).toBe(0);
});
test('drift detection after 10 consecutive negative CLV', () => {
const history = [1, 2, 0.5, -1, -2, -0.5, -1, -0.3, -2, -1.5, -0.8, -0.2, -1];
const result = checkDrift(history);
expect(result.drift_detected).toBe(true);
expect(result.consecutive_negative).toBe(10);
expect(result.alert).toBe(true);
});
test('no drift with mixed CLV history', () => {
const history = [1, -1, 2, -2, 0.5, -0.5, 1, -1, 0.3];
const result = checkDrift(history);
expect(result.drift_detected).toBe(false);
});
test('learning rate cap enforcement (max 0.05 delta)', () => {
expect(applyLearningRateCap(0.20, 0.30)).toBe(0.25);
expect(applyLearningRateCap(0.20, 0.10)).toBe(0.15);
expect(applyLearningRateCap(0.20, 0.22)).toBe(0.22);
expect(applyLearningRateCap(0.20, 0.20)).toBe(0.20);
});
});
// --- Correlation Math ---
describe('Correlation Math', () => {
test('phi coefficient calculation for joint outcomes', () => {
// Perfect positive correlation
const phi = phiCoefficient(50, 0, 0, 50);
expect(phi).toBe(1);
});
test('phi coefficient returns 0 for no correlation', () => {
const phi = phiCoefficient(25, 25, 25, 25);
expect(phi).toBe(0);
});
test('phi coefficient handles zero denominator', () => {
const phi = phiCoefficient(0, 0, 0, 0);
expect(phi).toBe(0);
});
test('hasMinimumObservations checks threshold', () => {
expect(hasMinimumObservations(100)).toBe(true);
expect(hasMinimumObservations(99)).toBe(false);
expect(hasMinimumObservations(50, 50)).toBe(true);
});
test('calculateJuiceAdjustedEV returns correct EV', () => {
// 60% win prob, -110 stake: 0.6*100 - 0.4*110 = 60 - 44 = 16
const ev = calculateJuiceAdjustedEV(0.6, 110);
expect(ev).toBeCloseTo(16, 0);
});
test('calculateJuiceAdjustedEV negative EV on bad bet', () => {
// 40% win prob: 0.4*100 - 0.6*110 = 40 - 66 = -26
const ev = calculateJuiceAdjustedEV(0.4, 110);
expect(ev).toBeLessThan(0);
});
});
});