Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,589 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// VYNDR — Supplement Intelligence Systems
|
||||
// Pure logic tests: all constants and formulas inlined
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Supplement Intelligence Systems', () => {
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// System 1 — Coaching Tendencies
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('Coaching Tendencies', () => {
|
||||
|
||||
const NBA_COACHING_FIELDS = [
|
||||
'pace_preference', 'rotation_depth', 'closing_lineup_consistency',
|
||||
'garbage_time_threshold', 'challenge_frequency', 'blitz_frequency',
|
||||
'zone_pct', 'rest_pattern', 'matchup_hunting_rate',
|
||||
'dnp_volatility', 'second_unit_usage', 'timeout_tendency',
|
||||
];
|
||||
|
||||
const MLB_COACHING_FIELDS = [
|
||||
'starter_hook_tendency', 'bullpen_deployment', 'platoon_aggressiveness',
|
||||
'sacrifice_bunt_rate', 'hit_and_run_rate', 'steal_aggressiveness',
|
||||
'defensive_shift_rate', 'lineup_consistency', 'rest_day_pattern',
|
||||
'challenge_aggressiveness',
|
||||
];
|
||||
|
||||
test('NBA coaching fields include all 12 expected keys', () => {
|
||||
expect(NBA_COACHING_FIELDS).toHaveLength(12);
|
||||
expect(NBA_COACHING_FIELDS).toContain('pace_preference');
|
||||
expect(NBA_COACHING_FIELDS).toContain('timeout_tendency');
|
||||
});
|
||||
|
||||
test('MLB coaching fields include all 10 expected keys', () => {
|
||||
expect(MLB_COACHING_FIELDS).toHaveLength(10);
|
||||
expect(MLB_COACHING_FIELDS).toContain('starter_hook_tendency');
|
||||
expect(MLB_COACHING_FIELDS).toContain('challenge_aggressiveness');
|
||||
});
|
||||
|
||||
test('parse_nba_coaching_decisions extracts rotation_depth from players with 10+ min', () => {
|
||||
const players = [
|
||||
{ minutes: 32 }, { minutes: 28 }, { minutes: 24 },
|
||||
{ minutes: 20 }, { minutes: 18 }, { minutes: 14 },
|
||||
{ minutes: 12 }, { minutes: 11 }, { minutes: 8 },
|
||||
{ minutes: 5 }, { minutes: 3 },
|
||||
];
|
||||
const rotation_depth = players.filter(p => p.minutes >= 10).length;
|
||||
expect(rotation_depth).toBe(8);
|
||||
});
|
||||
|
||||
test('rotation_depth of 8 from 8 players with 10+ minutes', () => {
|
||||
const playerMinutes = [35, 30, 28, 22, 18, 15, 12, 10, 7, 4];
|
||||
const rotation_depth = playerMinutes.filter(m => m >= 10).length;
|
||||
expect(rotation_depth).toBe(8);
|
||||
});
|
||||
|
||||
test('parse_mlb_coaching_decisions extracts starter_hook_tendency', () => {
|
||||
// Hook tendency = avg innings before pull across recent starts
|
||||
const starterInnings = [5.2, 6.0, 5.1, 4.2, 6.1];
|
||||
const avgHook = starterInnings.reduce((s, v) => s + v, 0) / starterInnings.length;
|
||||
expect(avgHook).toBeCloseTo(5.32, 1);
|
||||
});
|
||||
|
||||
test('shift detection: 15% threshold for flagging', () => {
|
||||
const SHIFT_THRESHOLD = 0.15;
|
||||
expect(SHIFT_THRESHOLD).toBe(0.15);
|
||||
});
|
||||
|
||||
test('shift detection: 10% change does NOT flag', () => {
|
||||
const SHIFT_THRESHOLD = 0.15;
|
||||
const baseline = 0.50;
|
||||
const current = 0.55;
|
||||
const change = Math.abs(current - baseline) / baseline;
|
||||
expect(change).toBeCloseTo(0.10, 4);
|
||||
expect(change < SHIFT_THRESHOLD).toBe(true);
|
||||
});
|
||||
|
||||
test('shift detection: 20% change DOES flag', () => {
|
||||
const SHIFT_THRESHOLD = 0.15;
|
||||
const baseline = 0.50;
|
||||
const current = 0.60;
|
||||
const change = Math.abs(current - baseline) / baseline;
|
||||
expect(change).toBeCloseTo(0.20, 4);
|
||||
expect(change >= SHIFT_THRESHOLD).toBe(true);
|
||||
});
|
||||
|
||||
test('shift detection returns direction increased or decreased', () => {
|
||||
const baseline = 0.50;
|
||||
const currentUp = 0.65;
|
||||
const currentDown = 0.35;
|
||||
const dirUp = currentUp > baseline ? 'increased' : 'decreased';
|
||||
const dirDown = currentDown > baseline ? 'increased' : 'decreased';
|
||||
expect(dirUp).toBe('increased');
|
||||
expect(dirDown).toBe('decreased');
|
||||
});
|
||||
|
||||
test('season baseline includes all numeric fields', () => {
|
||||
const seasonBaseline = {
|
||||
pace_preference: 98.5,
|
||||
rotation_depth: 9,
|
||||
closing_lineup_consistency: 0.72,
|
||||
zone_pct: 0.15,
|
||||
rest_pattern: 3.2,
|
||||
};
|
||||
for (const val of Object.values(seasonBaseline)) {
|
||||
expect(typeof val).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
test('recent tendencies calculated from last 15 games', () => {
|
||||
const RECENT_WINDOW = 15;
|
||||
const allGames = Array.from({ length: 40 }, (_, i) => ({ pace: 95 + (i % 10) }));
|
||||
const recentGames = allGames.slice(-RECENT_WINDOW);
|
||||
expect(recentGames).toHaveLength(15);
|
||||
});
|
||||
|
||||
test('coaching fields are sport-specific (NBA != MLB)', () => {
|
||||
const overlap = NBA_COACHING_FIELDS.filter(f => MLB_COACHING_FIELDS.includes(f));
|
||||
expect(overlap).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// System 2 — Redistribution Engine
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('Redistribution Engine', () => {
|
||||
|
||||
function classify_absorption_tier(boost, confidence) {
|
||||
if (boost >= 0.20 && confidence >= 0.75) return 'primary';
|
||||
if (boost >= 0.10 && confidence >= 0.60) return 'secondary';
|
||||
if (boost >= 0.05) return 'tertiary';
|
||||
return 'minimal';
|
||||
}
|
||||
|
||||
test('classify_absorption_tier: primary when boost>=0.20 AND confidence>=0.75', () => {
|
||||
expect(classify_absorption_tier(0.25, 0.80)).toBe('primary');
|
||||
expect(classify_absorption_tier(0.20, 0.75)).toBe('primary');
|
||||
});
|
||||
|
||||
test('classify_absorption_tier: secondary when boost>=0.10 AND confidence>=0.60', () => {
|
||||
expect(classify_absorption_tier(0.15, 0.65)).toBe('secondary');
|
||||
expect(classify_absorption_tier(0.10, 0.60)).toBe('secondary');
|
||||
});
|
||||
|
||||
test('classify_absorption_tier: tertiary when boost>=0.05', () => {
|
||||
expect(classify_absorption_tier(0.07, 0.40)).toBe('tertiary');
|
||||
expect(classify_absorption_tier(0.05, 0.10)).toBe('tertiary');
|
||||
});
|
||||
|
||||
test('classify_absorption_tier: minimal when boost<0.05', () => {
|
||||
expect(classify_absorption_tier(0.04, 0.90)).toBe('minimal');
|
||||
expect(classify_absorption_tier(0.01, 0.50)).toBe('minimal');
|
||||
});
|
||||
|
||||
test('concentrated coach (7-man): backup gets 70% of freed minutes', () => {
|
||||
const rotation_depth = 7;
|
||||
const minutes_freed = 30;
|
||||
const backup_share = rotation_depth <= 7 ? minutes_freed * 0.70 : minutes_freed / 4;
|
||||
expect(backup_share).toBe(21);
|
||||
});
|
||||
|
||||
test('distributed coach (10-man): minutes spread across 3-4 players', () => {
|
||||
const rotation_depth = 10;
|
||||
const minutes_freed = 30;
|
||||
const per_player_share = rotation_depth <= 7 ? minutes_freed * 0.70 : minutes_freed / 4;
|
||||
expect(per_player_share).toBe(7.5);
|
||||
// 4 players share equally
|
||||
expect(minutes_freed / 4).toBe(7.5);
|
||||
});
|
||||
|
||||
test('usage-efficiency tradeoff: -1.5% TS per +5% usage applied correctly', () => {
|
||||
// Formula: penalty = raw_boost * (-0.015 / 0.05)
|
||||
const raw_boost = 0.10;
|
||||
const penalty = raw_boost * (-0.015 / 0.05);
|
||||
expect(penalty).toBeCloseTo(-0.03, 4);
|
||||
});
|
||||
|
||||
test('net boost = raw_boost + efficiency_penalty', () => {
|
||||
const raw_boost = 0.10;
|
||||
const penalty = raw_boost * (-0.015 / 0.05);
|
||||
const net_boost = raw_boost + penalty;
|
||||
expect(net_boost).toBeCloseTo(0.07, 4);
|
||||
});
|
||||
|
||||
test('system change: primary_scorer out -> secondary_creator gets +0.08', () => {
|
||||
const SYSTEM_SHIFTS = {
|
||||
primary_scorer: { secondary_creator: 0.08, tertiary_scorer: 0.05 },
|
||||
primary_playmaker: { secondary_creator: 0.06 },
|
||||
interior_big: { stretch_big: 0.07 },
|
||||
};
|
||||
expect(SYSTEM_SHIFTS.primary_scorer.secondary_creator).toBe(0.08);
|
||||
});
|
||||
|
||||
test('system change: primary_playmaker out -> secondary_creator gets +0.06', () => {
|
||||
const SYSTEM_SHIFTS = {
|
||||
primary_scorer: { secondary_creator: 0.08 },
|
||||
primary_playmaker: { secondary_creator: 0.06 },
|
||||
interior_big: { stretch_big: 0.07 },
|
||||
};
|
||||
expect(SYSTEM_SHIFTS.primary_playmaker.secondary_creator).toBe(0.06);
|
||||
});
|
||||
|
||||
test('system change: interior_big out -> stretch_big gets +0.07', () => {
|
||||
const SYSTEM_SHIFTS = {
|
||||
primary_scorer: { secondary_creator: 0.08 },
|
||||
primary_playmaker: { secondary_creator: 0.06 },
|
||||
interior_big: { stretch_big: 0.07 },
|
||||
};
|
||||
expect(SYSTEM_SHIFTS.interior_big.stretch_big).toBe(0.07);
|
||||
});
|
||||
|
||||
test('auto-grade threshold: 15%+ boost AND 0.65+ confidence', () => {
|
||||
const AUTO_BOOST_THRESHOLD = 0.15;
|
||||
const AUTO_CONFIDENCE_THRESHOLD = 0.65;
|
||||
const should_auto_grade = (boost, conf) =>
|
||||
boost >= AUTO_BOOST_THRESHOLD && conf >= AUTO_CONFIDENCE_THRESHOLD;
|
||||
expect(should_auto_grade(0.18, 0.70)).toBe(true);
|
||||
expect(should_auto_grade(0.15, 0.65)).toBe(true);
|
||||
});
|
||||
|
||||
test('auto-grade: 14% boost does NOT trigger auto-grade', () => {
|
||||
const AUTO_BOOST_THRESHOLD = 0.15;
|
||||
const AUTO_CONFIDENCE_THRESHOLD = 0.65;
|
||||
const should_auto_grade = (boost, conf) =>
|
||||
boost >= AUTO_BOOST_THRESHOLD && conf >= AUTO_CONFIDENCE_THRESHOLD;
|
||||
expect(should_auto_grade(0.14, 0.90)).toBe(false);
|
||||
});
|
||||
|
||||
test('absorption alert format includes is OUT and is underpriced', () => {
|
||||
const playerOut = 'LeBron James';
|
||||
const beneficiary = 'Anthony Davis';
|
||||
const alert = `${playerOut} is OUT — ${beneficiary} is underpriced at current line`;
|
||||
expect(alert).toContain('is OUT');
|
||||
expect(alert).toContain('is underpriced');
|
||||
});
|
||||
|
||||
test('coach-specific redistribution_profile overrides generic system shifts', () => {
|
||||
const generic_boost = 0.08;
|
||||
const coach_profile = { secondary_creator_boost: 0.12 };
|
||||
const effective_boost = coach_profile.secondary_creator_boost || generic_boost;
|
||||
expect(effective_boost).toBe(0.12);
|
||||
expect(effective_boost).not.toBe(generic_boost);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// System 3 — Alt Line Scanner
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('Alt Line Scanner', () => {
|
||||
|
||||
const A_GRADES = ['A+', 'A', 'A-'];
|
||||
|
||||
function isEligibleForAltScan(grade) {
|
||||
return A_GRADES.includes(grade);
|
||||
}
|
||||
|
||||
test('only runs on A-grade props (A+, A, A-)', () => {
|
||||
expect(isEligibleForAltScan('A+')).toBe(true);
|
||||
expect(isEligibleForAltScan('A')).toBe(true);
|
||||
expect(isEligibleForAltScan('A-')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns eligible=false for B+ grade', () => {
|
||||
expect(isEligibleForAltScan('B+')).toBe(false);
|
||||
expect(isEligibleForAltScan('B')).toBe(false);
|
||||
expect(isEligibleForAltScan('C')).toBe(false);
|
||||
});
|
||||
|
||||
test('edge improvement threshold is 3% (0.03)', () => {
|
||||
const EDGE_IMPROVEMENT_THRESHOLD = 0.03;
|
||||
expect(EDGE_IMPROVEMENT_THRESHOLD).toBe(0.03);
|
||||
});
|
||||
|
||||
test('recommends alt when edge_vs_standard >= 0.03', () => {
|
||||
const EDGE_IMPROVEMENT_THRESHOLD = 0.03;
|
||||
const edge_vs_standard = 0.05;
|
||||
const recommend = edge_vs_standard >= EDGE_IMPROVEMENT_THRESHOLD;
|
||||
expect(recommend).toBe(true);
|
||||
});
|
||||
|
||||
test('does NOT recommend when edge_vs_standard < 0.03', () => {
|
||||
const EDGE_IMPROVEMENT_THRESHOLD = 0.03;
|
||||
const edge_vs_standard = 0.02;
|
||||
const recommend = edge_vs_standard >= EDGE_IMPROVEMENT_THRESHOLD;
|
||||
expect(recommend).toBe(false);
|
||||
});
|
||||
|
||||
test('alt lines sorted by ev_per_dollar descending', () => {
|
||||
const altLines = [
|
||||
{ line: 22.5, ev_per_dollar: 0.04 },
|
||||
{ line: 24.5, ev_per_dollar: 0.12 },
|
||||
{ line: 27.5, ev_per_dollar: 0.08 },
|
||||
];
|
||||
const sorted = [...altLines].sort((a, b) => b.ev_per_dollar - a.ev_per_dollar);
|
||||
expect(sorted[0].ev_per_dollar).toBe(0.12);
|
||||
expect(sorted[1].ev_per_dollar).toBe(0.08);
|
||||
expect(sorted[2].ev_per_dollar).toBe(0.04);
|
||||
});
|
||||
|
||||
test('returns top 5 positive EV alts only', () => {
|
||||
const altLines = [
|
||||
{ line: 20.5, ev_per_dollar: 0.15 },
|
||||
{ line: 21.5, ev_per_dollar: 0.12 },
|
||||
{ line: 22.5, ev_per_dollar: 0.09 },
|
||||
{ line: 23.5, ev_per_dollar: 0.06 },
|
||||
{ line: 24.5, ev_per_dollar: 0.03 },
|
||||
{ line: 25.5, ev_per_dollar: 0.01 },
|
||||
{ line: 26.5, ev_per_dollar: -0.02 },
|
||||
];
|
||||
const positiveEV = altLines
|
||||
.filter(a => a.ev_per_dollar > 0)
|
||||
.sort((a, b) => b.ev_per_dollar - a.ev_per_dollar)
|
||||
.slice(0, 5);
|
||||
expect(positiveEV).toHaveLength(5);
|
||||
expect(positiveEV.every(a => a.ev_per_dollar > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test('model probability calculation: over = 1 - CDF, under = CDF', () => {
|
||||
// Inline normalCDF approximation
|
||||
function normalCDF(x, mean, stddev) {
|
||||
const z = (x - mean) / stddev;
|
||||
const t = 1 / (1 + 0.2316419 * Math.abs(z));
|
||||
const d = 0.3989422804014327;
|
||||
const p = d * Math.exp(-z * z / 2) *
|
||||
(t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274)))));
|
||||
return z > 0 ? 1 - p : p;
|
||||
}
|
||||
const mean = 25, stddev = 5, line = 25;
|
||||
const prob_over = 1 - normalCDF(line, mean, stddev);
|
||||
const prob_under = normalCDF(line, mean, stddev);
|
||||
expect(prob_over).toBeCloseTo(0.5, 1);
|
||||
expect(prob_under).toBeCloseTo(0.5, 1);
|
||||
});
|
||||
|
||||
test('optimal alt is first element after sorting', () => {
|
||||
const sorted = [
|
||||
{ line: 24.5, ev_per_dollar: 0.12 },
|
||||
{ line: 22.5, ev_per_dollar: 0.08 },
|
||||
{ line: 27.5, ev_per_dollar: 0.04 },
|
||||
];
|
||||
const optimal = sorted[0];
|
||||
expect(optimal.line).toBe(24.5);
|
||||
expect(optimal.ev_per_dollar).toBe(0.12);
|
||||
});
|
||||
|
||||
test('alt line includes bookmaker field', () => {
|
||||
const altLine = { line: 22.5, odds: -130, book: 'draftkings', ev_per_dollar: 0.08 };
|
||||
expect(altLine).toHaveProperty('book');
|
||||
expect(altLine.book).toBe('draftkings');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// System 4 — Unconventional Data Pipeline
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('Unconventional Data Pipeline', () => {
|
||||
|
||||
const MIN_INSTANCES = 500;
|
||||
const MIN_PEARSON_R = 0.15;
|
||||
const BASE_ALPHA = 0.05;
|
||||
|
||||
function validateFactor(instances, r, p_value, num_tests) {
|
||||
if (instances < MIN_INSTANCES) return { validated: false, reason: 'insufficient_instances' };
|
||||
if (Math.abs(r) < MIN_PEARSON_R) return { validated: false, reason: 'weak_correlation' };
|
||||
const corrected_alpha = BASE_ALPHA / num_tests;
|
||||
if (p_value >= corrected_alpha) return { validated: false, reason: 'not_significant' };
|
||||
return { validated: true, corrected_alpha };
|
||||
}
|
||||
|
||||
const UNCONVENTIONAL_FACTORS = [
|
||||
{ name: 'travel_distance', validated: true },
|
||||
{ name: 'altitude_adjustment', validated: false },
|
||||
{ name: 'circadian_rhythm', validated: false },
|
||||
{ name: 'back_to_back_fatigue', validated: true },
|
||||
{ name: 'timezone_crossing', validated: false },
|
||||
];
|
||||
|
||||
test('validation requires minimum 500 instances', () => {
|
||||
expect(MIN_INSTANCES).toBe(500);
|
||||
});
|
||||
|
||||
test('fails with 499 instances', () => {
|
||||
const result = validateFactor(499, 0.20, 0.001, 4);
|
||||
expect(result.validated).toBe(false);
|
||||
expect(result.reason).toBe('insufficient_instances');
|
||||
});
|
||||
|
||||
test('passes with 500+ instances (if r and p pass)', () => {
|
||||
const result = validateFactor(600, 0.25, 0.001, 4);
|
||||
expect(result.validated).toBe(true);
|
||||
});
|
||||
|
||||
test('minimum Pearson r is 0.15', () => {
|
||||
expect(MIN_PEARSON_R).toBe(0.15);
|
||||
});
|
||||
|
||||
test('fails when r < 0.15', () => {
|
||||
const result = validateFactor(600, 0.10, 0.001, 4);
|
||||
expect(result.validated).toBe(false);
|
||||
expect(result.reason).toBe('weak_correlation');
|
||||
});
|
||||
|
||||
test('Bonferroni correction: alpha = 0.05 / number_of_active_tests', () => {
|
||||
const num_tests = 4;
|
||||
const corrected = BASE_ALPHA / num_tests;
|
||||
expect(corrected).toBe(0.0125);
|
||||
});
|
||||
|
||||
test('with 4 unvalidated factors, corrected alpha = 0.0125', () => {
|
||||
const unvalidated = UNCONVENTIONAL_FACTORS.filter(f => !f.validated);
|
||||
expect(unvalidated).toHaveLength(3);
|
||||
// When testing 4 factors simultaneously
|
||||
const corrected = BASE_ALPHA / 4;
|
||||
expect(corrected).toBe(0.0125);
|
||||
});
|
||||
|
||||
test('with 1 unvalidated factor, corrected alpha = 0.05', () => {
|
||||
const corrected = BASE_ALPHA / 1;
|
||||
expect(corrected).toBe(0.05);
|
||||
});
|
||||
|
||||
test('travel_distance starts as validated=True', () => {
|
||||
const travel = UNCONVENTIONAL_FACTORS.find(f => f.name === 'travel_distance');
|
||||
expect(travel.validated).toBe(true);
|
||||
});
|
||||
|
||||
test('altitude_adjustment starts as validated=False', () => {
|
||||
const altitude = UNCONVENTIONAL_FACTORS.find(f => f.name === 'altitude_adjustment');
|
||||
expect(altitude.validated).toBe(false);
|
||||
});
|
||||
|
||||
test('factor only enters grading engine AFTER validation (validated=True check)', () => {
|
||||
const activeFactors = UNCONVENTIONAL_FACTORS.filter(f => f.validated);
|
||||
expect(activeFactors.every(f => f.validated === true)).toBe(true);
|
||||
expect(activeFactors.map(f => f.name)).toContain('travel_distance');
|
||||
expect(activeFactors.map(f => f.name)).not.toContain('altitude_adjustment');
|
||||
});
|
||||
|
||||
test('status endpoint returns all 5 factor names', () => {
|
||||
expect(UNCONVENTIONAL_FACTORS).toHaveLength(5);
|
||||
const names = UNCONVENTIONAL_FACTORS.map(f => f.name);
|
||||
expect(names).toContain('travel_distance');
|
||||
expect(names).toContain('altitude_adjustment');
|
||||
expect(names).toContain('circadian_rhythm');
|
||||
expect(names).toContain('back_to_back_fatigue');
|
||||
expect(names).toContain('timezone_crossing');
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// System 5 — Evolution Alerting
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('Evolution Alerting', () => {
|
||||
|
||||
const MIN_GAMES = 15;
|
||||
const CHANGE_THRESHOLD = 0.10;
|
||||
const MIN_INFLECTIONS = 2;
|
||||
|
||||
const NBA_METRICS = ['usage_rate', 'assist_rate', 'three_pa_rate', 'fg_pct', 'minutes'];
|
||||
const MLB_METRICS = ['k_rate', 'bb_rate', 'exit_velocity', 'hard_hit_pct', 'fb_velo'];
|
||||
|
||||
function detectEvolution(gameCount, inflections) {
|
||||
if (gameCount < MIN_GAMES) return { evolution_detected: false, reason: 'insufficient_games' };
|
||||
if (inflections.length < MIN_INFLECTIONS) return { evolution_detected: false, reason: 'insufficient_inflections' };
|
||||
return {
|
||||
evolution_detected: true,
|
||||
detection_date: new Date().toISOString().split('T')[0],
|
||||
metrics: inflections,
|
||||
};
|
||||
}
|
||||
|
||||
function isInflection(baseline, current) {
|
||||
const change = Math.abs(current - baseline) / baseline;
|
||||
return change >= CHANGE_THRESHOLD;
|
||||
}
|
||||
|
||||
test('evolution detected when 2+ metrics show concurrent inflection', () => {
|
||||
const inflections = ['usage_rate', 'assist_rate'];
|
||||
const result = detectEvolution(20, inflections);
|
||||
expect(result.evolution_detected).toBe(true);
|
||||
});
|
||||
|
||||
test('evolution NOT detected with only 1 inflection', () => {
|
||||
const inflections = ['usage_rate'];
|
||||
const result = detectEvolution(20, inflections);
|
||||
expect(result.evolution_detected).toBe(false);
|
||||
expect(result.reason).toBe('insufficient_inflections');
|
||||
});
|
||||
|
||||
test('minimum 15 games required', () => {
|
||||
expect(MIN_GAMES).toBe(15);
|
||||
});
|
||||
|
||||
test('returns evolution_detected=false with 14 games', () => {
|
||||
const result = detectEvolution(14, ['usage_rate', 'assist_rate']);
|
||||
expect(result.evolution_detected).toBe(false);
|
||||
expect(result.reason).toBe('insufficient_games');
|
||||
});
|
||||
|
||||
test('change threshold is 10% (0.10)', () => {
|
||||
expect(CHANGE_THRESHOLD).toBe(0.10);
|
||||
});
|
||||
|
||||
test('9% change does NOT qualify as inflection', () => {
|
||||
const baseline = 0.20;
|
||||
const current = 0.218; // 9% change
|
||||
expect(isInflection(baseline, current)).toBe(false);
|
||||
});
|
||||
|
||||
test('11% change DOES qualify as inflection', () => {
|
||||
const baseline = 0.20;
|
||||
const current = 0.222; // 11% change
|
||||
expect(isInflection(baseline, current)).toBe(true);
|
||||
});
|
||||
|
||||
test('NBA metrics: usage_rate, assist_rate, three_pa_rate, fg_pct, minutes', () => {
|
||||
expect(NBA_METRICS).toEqual(['usage_rate', 'assist_rate', 'three_pa_rate', 'fg_pct', 'minutes']);
|
||||
expect(NBA_METRICS).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('MLB metrics: k_rate, bb_rate, exit_velocity, hard_hit_pct, fb_velo', () => {
|
||||
expect(MLB_METRICS).toEqual(['k_rate', 'bb_rate', 'exit_velocity', 'hard_hit_pct', 'fb_velo']);
|
||||
expect(MLB_METRICS).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('evolution record includes detection_date and metrics', () => {
|
||||
const inflections = ['usage_rate', 'three_pa_rate'];
|
||||
const result = detectEvolution(20, inflections);
|
||||
expect(result).toHaveProperty('detection_date');
|
||||
expect(result).toHaveProperty('metrics');
|
||||
expect(result.metrics).toEqual(inflections);
|
||||
});
|
||||
|
||||
test('Evolution Watch post format includes Evolution Watch', () => {
|
||||
const playerName = 'Jalen Brunson';
|
||||
const metrics = ['usage_rate', 'assist_rate'];
|
||||
const post = `Evolution Watch: ${playerName} — ${metrics.join(', ')} trending. The market hasn't priced it yet.`;
|
||||
expect(post).toContain('Evolution Watch');
|
||||
});
|
||||
|
||||
test('Evolution Watch post includes market hasn\'t priced it yet', () => {
|
||||
const post = `Evolution Watch: Player X — usage_rate trending. The market hasn't priced it yet.`;
|
||||
expect(post).toContain("market hasn't priced it yet");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Migration 008 — Table Definitions
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe('Migration 008', () => {
|
||||
|
||||
const sql = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'supabase', 'migrations', '008_supplement_tables.sql'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
test('creates coaching_tendencies table with UNIQUE constraint', () => {
|
||||
expect(sql).toContain('CREATE TABLE IF NOT EXISTS coaching_tendencies');
|
||||
expect(sql).toContain('UNIQUE(coach_id, team_id, sport, season)');
|
||||
});
|
||||
|
||||
test('creates player_out_history table', () => {
|
||||
expect(sql).toContain('CREATE TABLE IF NOT EXISTS player_out_history');
|
||||
expect(sql).toContain('player_out_id TEXT NOT NULL');
|
||||
expect(sql).toContain('beneficiary_stats JSONB NOT NULL');
|
||||
});
|
||||
|
||||
test('creates evolution_detections table', () => {
|
||||
expect(sql).toContain('CREATE TABLE IF NOT EXISTS evolution_detections');
|
||||
expect(sql).toContain('detection_date DATE NOT NULL');
|
||||
expect(sql).toContain('metrics JSONB NOT NULL');
|
||||
});
|
||||
|
||||
test('creates unconventional_validations with RLS', () => {
|
||||
expect(sql).toContain('CREATE TABLE IF NOT EXISTS unconventional_validations');
|
||||
expect(sql).toContain('ALTER TABLE unconventional_validations ENABLE ROW LEVEL SECURITY');
|
||||
expect(sql).toContain('factor_name TEXT NOT NULL');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user