/** * roleProfileEngine.js * Role profiling and classification engine for player analysis. * Estimates what basketball role(s) a player fills and detects shifts. */ const ROLE_TAXONOMY = [ 'PRIMARY_BALL_HANDLER', 'SECONDARY_PLAYMAKER', 'CATCH_SHOOT_SPACER', 'OFF_BALL_CUTTER', 'FLOOR_RAISER', 'SWITCHABLE_DEFENDER', 'PAINT_PRESENCE', 'CONNECTOR', ]; const CONDITIONAL_KEYS = [ 'star_out', 'losing_10_plus', 'foul_trouble', 'closing_lineup', 'winning_15_plus', ]; /** * Shannon entropy normalized to 0-1 range. * 0 = single role, 1 = equally distributed across all active roles. * * H = -sum(p_i * log2(p_i)) for all p_i > 0 * Normalized: H / log2(n) where n = number of non-zero roles * * @param {Object} roleProfile — keys are role names, values are weights (should sum to ~1) * @returns {number} role_variance_score in [0, 1] */ function calculateRoleVariance(roleProfile) { const weights = Object.values(roleProfile).filter((w) => w > 0); const n = weights.length; if (n <= 1) return 0; const total = weights.reduce((sum, w) => sum + w, 0); if (total === 0) return 0; // Normalize to probabilities const probs = weights.map((w) => w / total); // Shannon entropy const H = -probs.reduce((sum, p) => { return sum + (p > 0 ? p * Math.log2(p) : 0); }, 0); // Normalize by max possible entropy for n categories const maxH = Math.log2(n); if (maxH === 0) return 0; return Math.min(1, Math.max(0, H / maxH)); } /** * Returns the dominant (highest-weight) role from a profile. * @param {Object} roleProfile * @returns {string|null} role key with highest weight, or null if empty */ function getDominantRole(roleProfile) { if (!roleProfile || Object.keys(roleProfile).length === 0) return null; let maxKey = null; let maxVal = -Infinity; for (const [key, val] of Object.entries(roleProfile)) { if (val > maxVal) { maxVal = val; maxKey = key; } } return maxKey; } /** * Detect whether tonight's role profile represents a meaningful elevation * from the player's baseline. * * @param {Object} baseProfile — season/rolling baseline role distribution * @param {Object} tonightProfile — tonight's role distribution * @param {number} threshold — delta above which we flag elevation (default 0.20) * @returns {{ elevated: boolean, elevatedRole: string|null, delta: number }} */ function detectRoleElevation(baseProfile, tonightProfile, threshold = 0.20) { const baseDominant = getDominantRole(baseProfile); const tonightDominant = getDominantRole(tonightProfile); if (!baseDominant || !tonightDominant) { return { elevated: false, elevatedRole: null, delta: 0 }; } // Find the role with the largest positive shift from base to tonight let maxDelta = 0; let elevatedRole = null; for (const role of Object.keys(tonightProfile)) { const baseWeight = baseProfile[role] || 0; const tonightWeight = tonightProfile[role] || 0; const delta = tonightWeight - baseWeight; if (delta > maxDelta) { maxDelta = delta; elevatedRole = role; } } const elevated = maxDelta > threshold; return { elevated, elevatedRole: elevated ? elevatedRole : null, delta: Math.round(maxDelta * 1000) / 1000, }; } /** * Look up the conditional role profile for a given game condition. * * @param {Object} conditionalRoles — map of condition -> roleProfile * @param {string} condition — one of CONDITIONAL_KEYS * @returns {Object|null} the conditional role profile, or null if not found */ function getConditionalProfile(conditionalRoles, condition) { if (!conditionalRoles || !condition) return null; if (!CONDITIONAL_KEYS.includes(condition)) return null; return conditionalRoles[condition] || null; } /** * Estimate a role profile distribution from raw game log stats. * * Heuristic mapping: * - HIGH usage_rate + HIGH assist_rate => PRIMARY_BALL_HANDLER * - MED usage_rate + HIGH assist_rate => SECONDARY_PLAYMAKER * - LOW usage_rate + HIGH 3pt_share => CATCH_SHOOT_SPACER * - HIGH off_ball_movement + cuts => OFF_BALL_CUTTER * - HIGH usage_rate + LOW assist_rate => FLOOR_RAISER * - Defensive metrics => SWITCHABLE_DEFENDER * - HIGH paint touches + rebounds => PAINT_PRESENCE * - MED everything => CONNECTOR * * @param {Array} gameLogStats — array of game stat objects * @returns {Object} role profile with weights summing to ~1.0 */ function calculateRoleProfile(gameLogStats) { if (!gameLogStats || gameLogStats.length === 0) { return {}; } // Average the stats across games const avg = {}; const statKeys = [ 'usage_rate', 'assist_rate', 'three_point_share', 'off_ball_movement', 'paint_touches', 'rebounds_per_game', 'defensive_versatility', 'screen_assists', ]; for (const key of statKeys) { const vals = gameLogStats .map((g) => g[key]) .filter((v) => v !== undefined && v !== null); avg[key] = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; } // Raw role signals (0-1 scale heuristics) const raw = {}; // PRIMARY_BALL_HANDLER: high usage + high assists raw.PRIMARY_BALL_HANDLER = Math.min(1, (avg.usage_rate / 35) * 0.6 + (avg.assist_rate / 40) * 0.4); // SECONDARY_PLAYMAKER: moderate usage + high assists const secondaryUsage = avg.usage_rate >= 15 && avg.usage_rate <= 25 ? 1 : 0.3; raw.SECONDARY_PLAYMAKER = Math.min(1, secondaryUsage * 0.4 + (avg.assist_rate / 30) * 0.6); // CATCH_SHOOT_SPACER: low usage + high 3pt share const lowUsageBonus = avg.usage_rate < 20 ? 0.7 : 0.2; raw.CATCH_SHOOT_SPACER = Math.min(1, lowUsageBonus * 0.4 + (avg.three_point_share / 80) * 0.6); // OFF_BALL_CUTTER: off-ball movement driven raw.OFF_BALL_CUTTER = Math.min(1, (avg.off_ball_movement / 100) * 0.8 + (1 - avg.usage_rate / 40) * 0.2); // FLOOR_RAISER: high usage + low assists (score-first) const lowAssistBonus = avg.assist_rate < 15 ? 0.7 : 0.2; raw.FLOOR_RAISER = Math.min(1, (avg.usage_rate / 35) * 0.6 + lowAssistBonus * 0.4); // SWITCHABLE_DEFENDER: defensive versatility raw.SWITCHABLE_DEFENDER = Math.min(1, (avg.defensive_versatility / 100)); // PAINT_PRESENCE: paint touches + rebounds raw.PAINT_PRESENCE = Math.min(1, (avg.paint_touches / 15) * 0.5 + (avg.rebounds_per_game / 12) * 0.5); // CONNECTOR: screen assists + moderate everything raw.CONNECTOR = Math.min(1, (avg.screen_assists / 8) * 0.5 + 0.5 * (1 - calculateRoleVariance(raw))); // Normalize to sum to 1 const total = Object.values(raw).reduce((s, v) => s + v, 0); const profile = {}; if (total === 0) { // Fallback: equal distribution for (const role of ROLE_TAXONOMY) { profile[role] = 1 / ROLE_TAXONOMY.length; } } else { for (const role of ROLE_TAXONOMY) { profile[role] = Math.round(((raw[role] || 0) / total) * 1000) / 1000; } } return profile; } module.exports = { ROLE_TAXONOMY, CONDITIONAL_KEYS, calculateRoleVariance, getDominantRole, detectRoleElevation, getConditionalProfile, calculateRoleProfile, };