156 lines
5.1 KiB
JavaScript
156 lines
5.1 KiB
JavaScript
/**
|
|
* 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<Object>} historicalActivations — array of { date, roleProfile } objects,
|
|
* ordered oldest to newest
|
|
* @returns {{ stability_score: number, role_change_events: number, decay_weights_by_period: Array<number> }}
|
|
*/
|
|
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<Object>} instances — historical activations, oldest first
|
|
* @param {number} roleVarianceScore — 0-1
|
|
* @returns {Array<number>} 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,
|
|
};
|