const axios = require('axios'); const { getRedisClient } = require('../utils/redis'); const SCHEME_TYPES = ['DROP', 'SWITCH', 'HEDGE', 'MIXED', 'UNKNOWN']; const MIN_POSSESSIONS = 8; const CACHE_TTL = 21600; // 6 hours in seconds const NBA_STATS_BASE = process.env.NBA_STATS_URL || 'http://localhost:8000'; const PYTHON_SERVICE_BASE = process.env.PYTHON_SERVICE_URL || 'http://localhost:5001'; /** * Get cache key for scheme classification. * Keyed per opponent per game day. */ function getCacheKey(opponentTeam, gameDate) { return `scheme:${opponentTeam}:${gameDate}`; } /** * Fetch play-by-play data for a team's last 5 games. * Returns raw possession data for PnR analysis. */ async function fetchPlayByPlay(teamAbbr) { const url = `${NBA_STATS_BASE}/team/playbyplay`; const response = await axios.get(url, { params: { team: teamAbbr, last_n_games: 5 }, timeout: 15000, }); return response.data; } /** * Extract pick-and-roll defensive possessions from play-by-play data. * Looks for PnR ball handler and roll man actions in the play descriptions. */ function extractPnRPossessions(plays) { if (!Array.isArray(plays)) return []; const pnrIndicators = [ /pick.?and.?roll/i, /screen.*roll/i, /ball.?screen/i, /pnr/i, /hedge/i, /drop.*coverage/i, /switch.*screen/i, /ice.*screen/i, /blitz.*screen/i, /trap.*screen/i, ]; return plays.filter((play) => { const desc = play.description || play.play_description || ''; return pnrIndicators.some((pattern) => pattern.test(desc)); }); } /** * Classify the defensive coverage scheme from PnR possessions. * Requires minimum 8 possessions to produce a classification. */ function classifyScheme(pnrPossessions) { if (!pnrPossessions || pnrPossessions.length < MIN_POSSESSIONS) { return { scheme: 'UNKNOWN', confidence: 0, possessions_analyzed: pnrPossessions ? pnrPossessions.length : 0, reason: 'insufficient_data' }; } const counts = { DROP: 0, SWITCH: 0, HEDGE: 0 }; for (const poss of pnrPossessions) { const desc = (poss.description || poss.play_description || '').toLowerCase(); if (/drop|sag|contain|soft/i.test(desc)) { counts.DROP++; } else if (/switch|swap/i.test(desc)) { counts.SWITCH++; } else if (/hedge|blitz|trap|hard.*show|ice/i.test(desc)) { counts.HEDGE++; } } const total = counts.DROP + counts.SWITCH + counts.HEDGE; if (total === 0) { return { scheme: 'UNKNOWN', confidence: 0, possessions_analyzed: pnrPossessions.length, reason: 'no_classifiable_actions' }; } const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1]); const topScheme = dominant[0][0]; const topCount = dominant[0][1]; const dominance = topCount / total; // MIXED if no scheme exceeds 55% dominance if (dominance < 0.55) { return { scheme: 'MIXED', confidence: Math.round(dominance * 100), possessions_analyzed: pnrPossessions.length, breakdown: counts, }; } return { scheme: topScheme, confidence: Math.round(dominance * 100), possessions_analyzed: pnrPossessions.length, breakdown: counts, }; } /** * Fetch defensive scheme from Python Synergy service. * Returns full defensive play type distribution when available. * Falls back to null if Synergy service is unavailable. */ async function fetchSynergyScheme(teamId) { try { const url = `${PYTHON_SERVICE_BASE}/api/synergy/defensive-scheme/${teamId}`; const response = await axios.get(url, { timeout: 10000 }); const data = response.data; if (data && data.defensive_distribution) { return { scheme: classifyFromDistribution(data.defensive_distribution), distribution: data.defensive_distribution, source: 'synergy', }; } return null; } catch (e) { // Synergy unavailable — fallback to regex console.warn('[VYNDR] Synergy scheme fetch unavailable:', e.message); return null; } } /** * Classify scheme from Synergy defensive play type distribution. * Maps play type frequencies to scheme classification. */ function classifyFromDistribution(distribution) { if (!distribution || Object.keys(distribution).length === 0) return 'UNKNOWN'; const pnrHandler = distribution['PRBallHandler'] || {}; const pnrRollman = distribution['PRRollman'] || {}; const isolation = distribution['Isolation'] || {}; const pnrFreq = (pnrHandler.frequency_pct || 0) + (pnrRollman.frequency_pct || 0); if (pnrFreq < 0.05) return 'UNKNOWN'; // too little PnR data // High PPP allowed on PnR = likely DROP (giving up mid-range) // Low PPP on PnR = likely SWITCH or HEDGE (disrupting) const pnrPPP = pnrHandler.ppp || 0; const pnrTO = pnrHandler.to_pct || 0; if (pnrPPP > 0.95 && pnrTO < 0.10) return 'DROP'; if (pnrPPP < 0.80 && pnrTO > 0.15) return 'HEDGE'; if (isolation.frequency_pct > 0.15) return 'SWITCH'; // switch-heavy teams force isolations return 'MIXED'; } /** * Get scheme classification for an opponent. * Tries Synergy first, falls back to play-by-play regex. * Checks cache first. Graceful degradation: returns UNKNOWN if all sources unavailable. */ async function getSchemeClassification(opponentTeam, gameDate) { const redis = getRedisClient(); const cacheKey = getCacheKey(opponentTeam, gameDate || new Date().toISOString().split('T')[0]); // Check cache try { const cached = await redis.get(cacheKey); if (cached) { return { ...JSON.parse(cached), source: 'cache' }; } } catch (e) { // Redis failure is non-fatal console.warn('[VYNDR] Scheme cache read error:', e.message); } // Try Synergy service first (enhanced path) try { const synergyResult = await fetchSynergyScheme(opponentTeam); if (synergyResult) { const result = { opponent: opponentTeam, game_date: gameDate || new Date().toISOString().split('T')[0], ...synergyResult, classified_at: new Date().toISOString(), }; try { await redis.set(cacheKey, JSON.stringify(result), 'EX', CACHE_TTL); } catch (e) { /* non-fatal */ } return { ...result, source: 'synergy' }; } } catch (e) { console.warn('[VYNDR] Synergy fallthrough to regex:', e.message); } // Fallback to play-by-play regex classification try { const pbpData = await fetchPlayByPlay(opponentTeam); const plays = pbpData.plays || pbpData.play_by_play || []; const pnrPossessions = extractPnRPossessions(plays); const classification = classifyScheme(pnrPossessions); const result = { opponent: opponentTeam, game_date: gameDate || new Date().toISOString().split('T')[0], ...classification, classified_at: new Date().toISOString(), }; // Cache the result try { await redis.set(cacheKey, JSON.stringify(result), 'EX', CACHE_TTL); } catch (e) { console.warn('[VYNDR] Scheme cache write error:', e.message); } return { ...result, source: 'live' }; } catch (e) { // Graceful degradation — grade still produces even if this service is down console.warn('[VYNDR] Scheme classifier unavailable:', e.message); return { opponent: opponentTeam, game_date: gameDate || new Date().toISOString().split('T')[0], scheme: 'UNKNOWN', confidence: 0, possessions_analyzed: 0, reason: 'api_unavailable', source: 'fallback', }; } } /** * Log scheme classification to model_predictions_extended table. * Phase 1: Data collection only. Not user-visible. * User-visible activation happens Day 31. */ async function logSchemeToExtended(predictionId, schemeResult, supabaseClient) { if (!supabaseClient || !predictionId) return; try { const { error } = await supabaseClient .from('model_predictions_extended') .update({ active_role_tonight: `scheme:${schemeResult.scheme}`, model_version: '1.0-scheme', }) .eq('prediction_id', predictionId); if (error) { console.warn('[VYNDR] Scheme log write error:', error.message); } } catch (e) { // Non-fatal — silent logging only console.warn('[VYNDR] Scheme log error:', e.message); } } module.exports = { SCHEME_TYPES, MIN_POSSESSIONS, CACHE_TTL, getCacheKey, fetchPlayByPlay, fetchSynergyScheme, classifyFromDistribution, extractPnRPossessions, classifyScheme, getSchemeClassification, logSchemeToExtended, };