/** * shipResolution.test.js * Tests the resolution pipeline, calibration, archetype/weights, parlay, * capper content, and migration SQL for the VYNDR ship build. */ const fs = require('fs'); const path = require('path'); // ---------- Migration SQL ---------- const sql005 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/005_lineup_scheme_data.sql'), 'utf8'); const sql006 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/006_data_warehouse_calibration.sql'), 'utf8'); const sql007 = fs.readFileSync(path.join(__dirname, '../../supabase/migrations/007_lineup_odds_trust_health.sql'), 'utf8'); // ========================================================== // Helpers — inline logic that mirrors production functions // ========================================================== function resolveHit(direction, propLine, actualValue) { if (direction === 'over') return actualValue > propLine; if (direction === 'under') return actualValue < propLine; return null; } function calculateCLV(openingLine, closingLine, direction) { if (openingLine == null || closingLine == null) return null; const movement = closingLine - openingLine; const favorable = direction === 'over' ? movement > 0 : movement < 0; return { clv: favorable ? Math.abs(movement) : -Math.abs(movement), clv_magnitude: Math.abs(movement), }; } function modelMarketAlignment(openingLine, closingLine, direction) { if (openingLine == null || closingLine == null) return null; const movement = closingLine - openingLine; const marketMovedWithUs = (direction === 'over' && movement > 0) || (direction === 'under' && movement < 0); return marketMovedWithUs ? 'confirming' : 'contrarian'; } function createJointOutcomes(grades) { const resolved = grades.filter((g) => g.hit != null); const pairs = []; for (let i = 0; i < resolved.length; i++) { for (let j = i + 1; j < resolved.length; j++) { pairs.push({ player_a_id: resolved[i].player_id, player_b_id: resolved[j].player_id, stat_a: resolved[i].stat_type, stat_b: resolved[j].stat_type, hit_a: resolved[i].hit, hit_b: resolved[j].hit, }); } } return pairs; } function shouldRecalibrate(sampleSize) { return [25, 50, 75, 100].includes(sampleSize); } function clampOffset(raw) { return Math.max(-0.15, Math.min(0.15, raw)); } function brierScore(confidence, hit) { return (confidence - (hit ? 1 : 0)) ** 2; } function detectBlindSpots(grades) { if (grades.length < 200) return []; const grouped = {}; for (const g of grades) { const key = `${g.stat_type}|${g.context}`; if (!grouped[key]) grouped[key] = { total: 0, hits: 0 }; grouped[key].total += 1; if (g.hit) grouped[key].hits += 1; } const overall = grades.filter((g) => g.hit).length / grades.length; const spots = []; for (const [key, val] of Object.entries(grouped)) { const rate = val.hits / val.total; if ((overall - rate) / overall >= 0.25) { spots.push({ key, rate, degradation: (overall - rate) / overall }); } } return spots; } function catastrophicMisses(grades) { const resolved = grades.filter((g) => g.hit === false && g.actual_value != null); resolved.sort((a, b) => Math.abs(b.actual_value - b.prop_line) - Math.abs(a.actual_value - a.prop_line)); const cutoff = Math.max(5, Math.ceil(resolved.length * 0.05)); return resolved.slice(0, cutoff); } // NBA archetype detection & weight blending const ARCHETYPE_WEIGHTS = { primary_scorer: { matchup_defense: 0.30, usage_context: 0.25, recent_form: 0.20, pace_impact: 0.15, rest_travel: 0.10 }, secondary_creator: { usage_context: 0.35, matchup_defense: 0.20, recent_form: 0.20, pace_impact: 0.15, rest_travel: 0.10 }, stretch_big: { matchup_defense: 0.25, usage_context: 0.20, recent_form: 0.20, pace_impact: 0.20, rest_travel: 0.15 }, default: { matchup_defense: 0.20, usage_context: 0.20, recent_form: 0.20, pace_impact: 0.20, rest_travel: 0.20 }, }; function detectArchetypes(profile) { const scores = {}; if (profile.pts_per_game >= 22 && profile.usage_rate >= 0.28) { scores.primary_scorer = (profile.pts_per_game / 30) * 0.5 + (profile.usage_rate / 0.35) * 0.5; } if (profile.ast_per_game >= 5 && profile.usage_rate >= 0.20) { scores.secondary_creator = (profile.ast_per_game / 10) * 0.5 + (profile.usage_rate / 0.30) * 0.5; } if (profile.reb_per_game >= 7 && profile.three_pa_rate >= 0.25) { scores.stretch_big = (profile.reb_per_game / 12) * 0.5 + (profile.three_pa_rate / 0.40) * 0.5; } return scores; } function blendWeights(archetypeScores) { const entries = Object.entries(archetypeScores).filter(([, s]) => s >= 0.1); if (entries.length === 0) return { ...ARCHETYPE_WEIGHTS.default }; const totalScore = entries.reduce((s, [, v]) => s + v, 0); const blended = {}; for (const [arch, score] of entries) { const proportion = score / totalScore; const aw = ARCHETYPE_WEIGHTS[arch]; for (const [dim, w] of Object.entries(aw)) { blended[dim] = (blended[dim] || 0) + w * proportion; } } return blended; } // Parlay helpers function isSameGame(legs) { const gameIds = legs.map((l) => l.game_id); return new Set(gameIds).size === 1; } function structuralPenalty(legCount) { return legCount > 2 ? (legCount - 2) * 0.03 : 0; } function canComputePhi(jointCount) { return jointCount >= 30; } // Capper content formatters function formatCapperPick(pick) { return `VYNDR Scan #${pick.number}: ${pick.player} ${pick.direction} ${pick.line} ${pick.stat}`; } function formatBreakingAlert(alert) { return `BREAKING: ${alert.player} — ${alert.message}`; } function formatDailyResults(results) { return results.map((r) => `${r.hit ? '✅' : '❌'} ${r.player} ${r.stat} ${r.line}`).join('\n'); } function formatMissAutopsy(miss) { return `${miss.player} ${miss.stat} ${miss.line}\nWhy: ${miss.reason}`; } // ========================================================== // TESTS // ========================================================== // ---- 1. Resolution hit/miss ---- describe('Resolution hit/miss', () => { test('over hit when actual > line', () => { expect(resolveHit('over', 22.5, 28)).toBe(true); }); test('over miss when actual < line', () => { expect(resolveHit('over', 22.5, 18)).toBe(false); }); test('under hit when actual < line', () => { expect(resolveHit('under', 22.5, 18)).toBe(true); }); test('under miss when actual > line', () => { expect(resolveHit('under', 22.5, 28)).toBe(false); }); }); // ---- 2. CLV calculation ---- describe('CLV calculation', () => { test('positive CLV when closing moves toward our direction', () => { const result = calculateCLV(22.5, 24.0, 'over'); expect(result.clv).toBeGreaterThan(0); }); test('negative CLV when closing moves against our direction', () => { const result = calculateCLV(22.5, 20.0, 'over'); expect(result.clv).toBeLessThan(0); }); test('null when no odds data', () => { expect(calculateCLV(null, null, 'over')).toBeNull(); }); test('clv_magnitude is abs(movement)', () => { const result = calculateCLV(22.5, 20.0, 'over'); expect(result.clv_magnitude).toBe(2.5); }); }); // ---- 3. Model-market alignment ---- describe('Model-market alignment', () => { test('confirming when market moves with us (over)', () => { expect(modelMarketAlignment(22.5, 24.0, 'over')).toBe('confirming'); }); test('contrarian when market moves against us', () => { expect(modelMarketAlignment(22.5, 20.0, 'over')).toBe('contrarian'); }); test('null when no data', () => { expect(modelMarketAlignment(null, null, 'over')).toBeNull(); }); }); // ---- 4. Joint outcome logging ---- describe('Joint outcome logging', () => { test('creates pair for same-game grades', () => { const grades = [ { player_id: 'A', stat_type: 'pts', hit: true }, { player_id: 'B', stat_type: 'reb', hit: false }, ]; const pairs = createJointOutcomes(grades); expect(pairs).toHaveLength(1); expect(pairs[0].player_a_id).toBe('A'); expect(pairs[0].player_b_id).toBe('B'); }); test('skips self-pair', () => { const grades = [{ player_id: 'A', stat_type: 'pts', hit: true }]; const pairs = createJointOutcomes(grades); expect(pairs).toHaveLength(0); }); test('skips unresolved pairs', () => { const grades = [ { player_id: 'A', stat_type: 'pts', hit: true }, { player_id: 'B', stat_type: 'reb', hit: null }, ]; const pairs = createJointOutcomes(grades); expect(pairs).toHaveLength(0); }); }); // ---- 5. Calibration thresholds ---- describe('Calibration thresholds', () => { test('triggers recalibration at 25, 50, 75, 100', () => { expect(shouldRecalibrate(25)).toBe(true); expect(shouldRecalibrate(50)).toBe(true); expect(shouldRecalibrate(75)).toBe(true); expect(shouldRecalibrate(100)).toBe(true); expect(shouldRecalibrate(30)).toBe(false); }); test('point-biserial correlation bounds 0.05–0.50', () => { const lower = 0.05; const upper = 0.50; const validCorrelation = 0.22; expect(validCorrelation).toBeGreaterThanOrEqual(lower); expect(validCorrelation).toBeLessThanOrEqual(upper); expect(0.01).toBeLessThan(lower); expect(0.55).toBeGreaterThan(upper); }); test('global offset thresholds at 100/250/500/1000', () => { const thresholds = [100, 250, 500, 1000]; expect(thresholds).toContain(100); expect(thresholds).toContain(250); expect(thresholds).toContain(500); expect(thresholds).toContain(1000); }); }); // ---- 6. Global offset clamp ---- describe('Global offset clamp', () => { test('clamped to 0.15 max', () => { expect(clampOffset(0.30)).toBe(0.15); }); test('clamped to -0.15 min', () => { expect(clampOffset(-0.25)).toBe(-0.15); }); }); // ---- 7. Brier score update ---- describe('Brier score', () => { test('Brier = 0.0 for perfect prediction', () => { expect(brierScore(1.0, true)).toBeCloseTo(0.0); }); test('Brier = 0.25 for coin flip', () => { expect(brierScore(0.5, true)).toBeCloseTo(0.25); }); }); // ---- 8. Blind spot detection ---- describe('Blind spot detection', () => { test('requires 200+ grades minimum', () => { const grades = Array.from({ length: 150 }, (_, i) => ({ stat_type: 'pts', context: 'home', hit: i % 2 === 0, })); expect(detectBlindSpots(grades)).toEqual([]); }); test('flags 25%+ degradation', () => { // 200 grades: 140 hit overall (70%), but stat_type=reb|away: 10 total, 3 hit (30%) → degradation = (0.7-0.3)/0.7 = 0.57 const grades = []; for (let i = 0; i < 190; i++) { grades.push({ stat_type: 'pts', context: 'home', hit: i < 137 }); } for (let i = 0; i < 10; i++) { grades.push({ stat_type: 'reb', context: 'away', hit: i < 3 }); } const spots = detectBlindSpots(grades); expect(spots.length).toBeGreaterThanOrEqual(1); expect(spots[0].degradation).toBeGreaterThanOrEqual(0.25); }); test('returns empty below threshold', () => { const grades = Array.from({ length: 199 }, () => ({ stat_type: 'pts', context: 'home', hit: true, })); expect(detectBlindSpots(grades)).toEqual([]); }); }); // ---- 9. Catastrophic miss tracking ---- describe('Catastrophic miss tracking', () => { test('finds worst 5%', () => { const grades = []; for (let i = 0; i < 200; i++) { grades.push({ hit: false, actual_value: 10 + i, prop_line: 5, }); } const misses = catastrophicMisses(grades); expect(misses.length).toBe(10); // 5% of 200 }); test('minimum 5 misses returned', () => { const grades = []; for (let i = 0; i < 20; i++) { grades.push({ hit: false, actual_value: 30 + i, prop_line: 10 }); } const misses = catastrophicMisses(grades); expect(misses.length).toBeGreaterThanOrEqual(5); }); test('sorted by abs_error descending', () => { const grades = [ { hit: false, actual_value: 50, prop_line: 20 }, { hit: false, actual_value: 25, prop_line: 20 }, { hit: false, actual_value: 40, prop_line: 20 }, ]; const misses = catastrophicMisses(grades); const errors = misses.map((m) => Math.abs(m.actual_value - m.prop_line)); for (let i = 1; i < errors.length; i++) { expect(errors[i - 1]).toBeGreaterThanOrEqual(errors[i]); } }); }); // ---- 10. NBA archetype weight blending ---- describe('NBA archetype weight blending', () => { test('primary_scorer has matchup_defense at 0.30', () => { expect(ARCHETYPE_WEIGHTS.primary_scorer.matchup_defense).toBe(0.30); }); test('secondary_creator has usage_context at 0.35', () => { expect(ARCHETYPE_WEIGHTS.secondary_creator.usage_context).toBe(0.35); }); test('default weights when all archetype scores < 0.1', () => { const weights = blendWeights({ primary_scorer: 0.05, secondary_creator: 0.02 }); expect(weights).toEqual(ARCHETYPE_WEIGHTS.default); }); test('blending with multiple archetypes produces proportional mix', () => { const scores = { primary_scorer: 0.6, secondary_creator: 0.4 }; const weights = blendWeights(scores); // primary proportion = 0.6, secondary = 0.4, total = 1.0 const expectedMatchupDefense = 0.30 * 0.6 + 0.20 * 0.4; expect(weights.matchup_defense).toBeCloseTo(expectedMatchupDefense); }); test('weights sum to ~1.0', () => { const scores = { primary_scorer: 0.8, secondary_creator: 0.5 }; const weights = blendWeights(scores); const sum = Object.values(weights).reduce((a, b) => a + b, 0); expect(sum).toBeCloseTo(1.0, 2); }); test('stretch_big detected from reb_per_game + three_pa_rate', () => { const profile = { pts_per_game: 14, usage_rate: 0.18, ast_per_game: 2, reb_per_game: 9.5, three_pa_rate: 0.32, }; const archetypes = detectArchetypes(profile); expect(archetypes).toHaveProperty('stretch_big'); expect(archetypes.stretch_big).toBeGreaterThan(0); }); }); // ---- 11. Parlay correlation ---- describe('Parlay correlation', () => { test('same-game detected', () => { const legs = [ { game_id: 'G1', player: 'A' }, { game_id: 'G1', player: 'B' }, ]; expect(isSameGame(legs)).toBe(true); }); test('structural penalty 0.03 per extra leg beyond 2', () => { expect(structuralPenalty(2)).toBe(0); expect(structuralPenalty(3)).toBeCloseTo(0.03); expect(structuralPenalty(5)).toBeCloseTo(0.09); }); test('phi requires 30+ joints', () => { expect(canComputePhi(29)).toBe(false); expect(canComputePhi(30)).toBe(true); }); test('warning on 4+ legs', () => { const legCount = 4; const warning = legCount >= 4 ? 'High-leg parlay — correlation risk elevated' : null; expect(warning).not.toBeNull(); expect(warning).toContain('correlation'); }); }); // ---- 12. Capper content ---- describe('Capper content', () => { test('pick number increments', () => { const pick1 = formatCapperPick({ number: 1, player: 'Tatum', direction: 'over', line: 27.5, stat: 'pts' }); const pick2 = formatCapperPick({ number: 2, player: 'Brown', direction: 'under', line: 5.5, stat: 'ast' }); expect(pick1).toContain('#1'); expect(pick2).toContain('#2'); }); test('breaking alert format includes BREAKING', () => { const alert = formatBreakingAlert({ player: 'Embiid', message: 'ruled out' }); expect(alert).toContain('BREAKING'); }); test('standard format includes VYNDR Scan', () => { const pick = formatCapperPick({ number: 5, player: 'Jokic', direction: 'over', line: 11.5, stat: 'reb' }); expect(pick).toContain('VYNDR Scan'); }); test('daily results format includes hit/miss icons', () => { const results = formatDailyResults([ { hit: true, player: 'LeBron', stat: 'pts', line: 25.5 }, { hit: false, player: 'AD', stat: 'reb', line: 10.5 }, ]); expect(results).toContain('✅'); expect(results).toContain('❌'); }); test('miss autopsy includes Why:', () => { const autopsy = formatMissAutopsy({ player: 'Harden', stat: 'ast', line: 9.5, reason: 'Early foul trouble limited minutes', }); expect(autopsy).toContain('Why:'); }); }); // ---- 13. Migration 005 SQL ---- describe('Migration 005 SQL', () => { test('creates lineup_scheme_data', () => { expect(sql005).toContain('CREATE TABLE IF NOT EXISTS lineup_scheme_data'); }); test('has RLS', () => { expect(sql005).toContain('ENABLE ROW LEVEL SECURITY'); }); test('has indexes', () => { expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_team'); expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_hash'); expect(sql005).toContain('CREATE INDEX IF NOT EXISTS idx_ls_date'); }); }); // ---- 14. Migration 006 SQL ---- describe('Migration 006 SQL', () => { test('creates grade_outcomes with discipline_score column', () => { expect(sql006).toContain('CREATE TABLE IF NOT EXISTS grade_outcomes'); expect(sql006).toContain('discipline_score DECIMAL'); }); test('creates nba_data_cache', () => { expect(sql006).toContain('CREATE TABLE IF NOT EXISTS nba_data_cache'); }); test('creates player_calibrated_weights', () => { expect(sql006).toContain('CREATE TABLE IF NOT EXISTS player_calibrated_weights'); }); test('grade_outcomes has clv columns', () => { expect(sql006).toContain('clv_opening_line DECIMAL'); expect(sql006).toContain('clv_closing_line DECIMAL'); expect(sql006).toContain('clv_movement DECIMAL'); expect(sql006).toContain('clv_win BOOLEAN'); }); }); // ---- 15. Migration 007 SQL ---- describe('Migration 007 SQL', () => { test('creates reporter_trust with source_type', () => { expect(sql007).toContain('CREATE TABLE IF NOT EXISTS reporter_trust'); expect(sql007).toContain('source_type TEXT NOT NULL'); }); test('creates odds_warehouse', () => { expect(sql007).toContain('CREATE TABLE IF NOT EXISTS odds_warehouse'); }); test('creates reporter_line_correlation', () => { expect(sql007).toContain('CREATE TABLE IF NOT EXISTS reporter_line_correlation'); }); test('creates ship_joint_outcomes', () => { expect(sql007).toContain('CREATE TABLE IF NOT EXISTS ship_joint_outcomes'); }); test('creates global_calibration', () => { expect(sql007).toContain('CREATE TABLE IF NOT EXISTS global_calibration'); }); });