Files
vyndr/src/services/roleStabilityEngine.js
T

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,
};