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
+232
View File
@@ -0,0 +1,232 @@
/**
* 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<Object>} 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,
};