const SIMILARITY_WEIGHTS = { functional_role_match: 0.20, opponent_defensive_rating: 0.14, pace: 0.15, lineup_context: 0.12, rest_days: 0.09, travel_fatigue: 0.08, game_importance: 0.07, referee_tendency: 0.06, score_state_context: 0.05, role_variance_match: 0.04, }; /** * Calculate similarity score between two games based on weighted factors. * @param {object} gameA - First game context object * @param {object} gameB - Second game context object * @param {object} weights - Weight configuration (defaults to SIMILARITY_WEIGHTS) * @returns {number} Similarity score between 0 and 1 */ function calculateSimilarityScore(gameA, gameB, weights = SIMILARITY_WEIGHTS) { let totalScore = 0; let totalWeight = 0; for (const [factor, weight] of Object.entries(weights)) { if (gameA[factor] !== undefined && gameB[factor] !== undefined) { const maxVal = Math.max(Math.abs(gameA[factor]), Math.abs(gameB[factor]), 1); const diff = Math.abs(gameA[factor] - gameB[factor]) / maxVal; const similarity = Math.max(0, 1 - diff); totalScore += similarity * weight; totalWeight += weight; } } if (totalWeight === 0) return 0; return Math.min(1, Math.max(0, totalScore / totalWeight)); } /** * Find the most similar historical games to a target game. * @param {object} targetGame - The game to match against * @param {Array} historicalGames - Array of historical game objects * @param {number} minInstances - Minimum matches required (default 15) * @returns {object} { games: sorted matches, confidence: HIGH|LOW, usedSeasonAvg: boolean } */ function findSimilarGames(targetGame, historicalGames, minInstances = 15) { if (!historicalGames || historicalGames.length === 0) { return { games: [], confidence: 'LOW', usedSeasonAvg: true }; } const scored = historicalGames.map(game => ({ ...game, similarityScore: calculateSimilarityScore(targetGame, game), })); scored.sort((a, b) => b.similarityScore - a.similarityScore); if (scored.length < minInstances) { return { games: scored, confidence: 'LOW', usedSeasonAvg: true, note: `Only ${scored.length} similar games found (min: ${minInstances}). Falling back to season averages.`, }; } return { games: scored.slice(0, Math.max(minInstances, Math.floor(scored.length * 0.3))), confidence: 'HIGH', usedSeasonAvg: false, }; } /** * Calculate posterior distribution from similar games. * @param {Array} similarGames - Array of game objects with a stat value * @returns {object} { mean, stddev, ci_low, ci_high, n } */ function getPosteriorDistribution(similarGames) { if (!similarGames || similarGames.length === 0) { return { mean: 0, stddev: 0, ci_low: 0, ci_high: 0, n: 0 }; } const values = similarGames.map(g => g.statValue || 0); const n = values.length; const mean = values.reduce((sum, v) => sum + v, 0) / n; const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / (n - 1 || 1); const stddev = Math.sqrt(variance); const zScore = 1.96; // 95% CI const se = stddev / Math.sqrt(n); return { mean: Math.round(mean * 100) / 100, stddev: Math.round(stddev * 100) / 100, ci_low: Math.round((mean - zScore * se) * 100) / 100, ci_high: Math.round((mean + zScore * se) * 100) / 100, n, }; } module.exports = { SIMILARITY_WEIGHTS, calculateSimilarityScore, findSimilarGames, getPosteriorDistribution, };