/** * roleStabilityEngine.js * Measures how stable a player's role is over time and applies * recency decay for high-variance players. */ /** * Calculate a stability score for a player's role profile. * * Rules: * - role_variance_score below 0.2: zero decay regardless of age (locked-in role) * - role_variance_score above 0.5: apply recency decay (recent games weighted more) * - Between 0.2 and 0.5: partial decay scaling linearly * * @param {Object} roleProfile — current role distribution * @param {number} roleVarianceScore — output of calculateRoleVariance (0-1) * @param {Array} historicalActivations — array of { date, roleProfile } objects, * ordered oldest to newest * @returns {{ stability_score: number, role_change_events: number, decay_weights_by_period: Array }} */ function calculateStability(roleProfile, roleVarianceScore, historicalActivations) { if (!historicalActivations || historicalActivations.length === 0) { return { stability_score: roleVarianceScore <= 0.2 ? 1.0 : 0.5, role_change_events: 0, decay_weights_by_period: [], }; } const decayWeights = applyDecayWeights(historicalActivations, roleVarianceScore); // Count role change events: when the dominant role shifts between consecutive games let roleChangeEvents = 0; for (let i = 1; i < historicalActivations.length; i++) { const prevDominant = _getDominantFromActivation(historicalActivations[i - 1]); const currDominant = _getDominantFromActivation(historicalActivations[i]); if (prevDominant && currDominant && prevDominant !== currDominant) { roleChangeEvents++; } } // Stability is inverse of normalized change rate, adjusted by decay context const maxPossibleChanges = Math.max(1, historicalActivations.length - 1); const changeRate = roleChangeEvents / maxPossibleChanges; // Weighted consistency: how much the weighted recent profiles agree with current let weightedConsistency = 0; let totalWeight = 0; for (let i = 0; i < historicalActivations.length; i++) { const activation = historicalActivations[i]; const weight = decayWeights[i] || 1; const similarity = _profileSimilarity(roleProfile, activation.roleProfile || {}); weightedConsistency += similarity * weight; totalWeight += weight; } const avgConsistency = totalWeight > 0 ? weightedConsistency / totalWeight : 0.5; // Final stability: blend of low change rate and high consistency const stabilityScore = Math.min(1, Math.max(0, avgConsistency * 0.6 + (1 - changeRate) * 0.4 )); return { stability_score: Math.round(stabilityScore * 1000) / 1000, role_change_events: roleChangeEvents, decay_weights_by_period: decayWeights.map((w) => Math.round(w * 1000) / 1000), }; } /** * Apply recency decay weights to historical instances. * * - roleVarianceScore <= 0.2: all weights = 1.0 (no decay) * - roleVarianceScore >= 0.5: full exponential decay (lambda = 0.1) * - Between: linear interpolation of decay strength * * Newest instance (last in array) gets weight 1.0. * Older instances decay from there. * * @param {Array} instances — historical activations, oldest first * @param {number} roleVarianceScore — 0-1 * @returns {Array} array of weights, same length as instances */ function applyDecayWeights(instances, roleVarianceScore) { if (!instances || instances.length === 0) return []; const n = instances.length; // No decay for locked-in roles if (roleVarianceScore <= 0.2) { return new Array(n).fill(1.0); } // Decay strength: 0 at variance=0.2, 1.0 at variance>=0.5 const decayStrength = Math.min(1.0, Math.max(0, (roleVarianceScore - 0.2) / 0.3)); const lambda = 0.1 * decayStrength; const weights = []; for (let i = 0; i < n; i++) { // Distance from newest (last element) const age = n - 1 - i; const decayedWeight = Math.exp(-lambda * age); // Blend between no-decay (1.0) and full decay based on strength const weight = 1.0 * (1 - decayStrength) + decayedWeight * decayStrength; weights.push(weight); } return weights; } /** * Get dominant role from an activation record. * @private */ function _getDominantFromActivation(activation) { if (!activation || !activation.roleProfile) return null; const entries = Object.entries(activation.roleProfile); if (entries.length === 0) return null; return entries.reduce((best, curr) => (curr[1] > best[1] ? curr : best))[0]; } /** * Cosine-ish similarity between two role profiles. * Measures overlap of distributions. * @private */ function _profileSimilarity(profileA, profileB) { const allKeys = new Set([...Object.keys(profileA), ...Object.keys(profileB)]); if (allKeys.size === 0) return 1; let dotProduct = 0; let magA = 0; let magB = 0; for (const key of allKeys) { const a = profileA[key] || 0; const b = profileB[key] || 0; dotProduct += a * b; magA += a * a; magB += b * b; } const magnitude = Math.sqrt(magA) * Math.sqrt(magB); if (magnitude === 0) return 0; return dotProduct / magnitude; } module.exports = { calculateStability, applyDecayWeights, };