Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user