Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+271
View File
@@ -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,
};