Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user