106 lines
3.1 KiB
TypeScript
106 lines
3.1 KiB
TypeScript
/**
|
||
* Per-skill reputation scoring with time-based decay.
|
||
*
|
||
* Score formula: baseScore × decayFactor
|
||
* baseScore = (avgRating / 5) × 80 + (min(completedHours, 50) / 50) × 20
|
||
* decayFactor = e^(-λ × daysSinceLastRating) where λ = 0.005
|
||
*
|
||
* Score range: 0-100. New members start at 50 (neutral).
|
||
*/
|
||
|
||
import type { Skill } from './schemas';
|
||
import type { ReputationDoc, ReputationEntry, ReputationRating } from './schemas-intent';
|
||
|
||
// ── Constants ──
|
||
|
||
/** Default score for members with no reputation */
|
||
export const DEFAULT_REPUTATION = 50;
|
||
|
||
/** Decay rate (per day). At λ=0.005, score halves after ~139 days of inactivity */
|
||
const DECAY_LAMBDA = 0.005;
|
||
|
||
/** Rating weight (out of 100) */
|
||
const RATING_WEIGHT = 80;
|
||
|
||
/** Hours weight (out of 100) */
|
||
const HOURS_WEIGHT = 20;
|
||
|
||
/** Hours cap for scoring (diminishing returns beyond this) */
|
||
const HOURS_CAP = 50;
|
||
|
||
const MS_PER_DAY = 86_400_000;
|
||
|
||
// ── Scoring ──
|
||
|
||
/**
|
||
* Calculate the reputation score for a member-skill pair.
|
||
*/
|
||
export function calculateScore(entry: ReputationEntry, now: number = Date.now()): number {
|
||
if (entry.ratings.length === 0) return DEFAULT_REPUTATION;
|
||
|
||
// Average rating (1-5 scale)
|
||
const avgRating = entry.ratings.reduce((sum, r) => sum + r.score, 0) / entry.ratings.length;
|
||
|
||
// Base score: rating component + hours component
|
||
const ratingComponent = (avgRating / 5) * RATING_WEIGHT;
|
||
const hoursComponent = (Math.min(entry.completedHours, HOURS_CAP) / HOURS_CAP) * HOURS_WEIGHT;
|
||
const baseScore = ratingComponent + hoursComponent;
|
||
|
||
// Decay based on time since most recent rating
|
||
const lastRating = entry.ratings.reduce((latest, r) => Math.max(latest, r.timestamp), 0);
|
||
const daysSince = (now - lastRating) / MS_PER_DAY;
|
||
const decayFactor = Math.exp(-DECAY_LAMBDA * Math.max(0, daysSince));
|
||
|
||
return Math.round(baseScore * decayFactor);
|
||
}
|
||
|
||
/**
|
||
* Get a member's reputation for a specific skill, or DEFAULT_REPUTATION if none.
|
||
*/
|
||
export function getMemberSkillReputation(
|
||
memberId: string,
|
||
skill: Skill,
|
||
reputationDoc: ReputationDoc,
|
||
now?: number,
|
||
): number {
|
||
const key = `${memberId}:${skill}`;
|
||
const entry = reputationDoc.entries[key];
|
||
if (!entry) return DEFAULT_REPUTATION;
|
||
return calculateScore(entry, now);
|
||
}
|
||
|
||
/**
|
||
* Get a member's average reputation across all skills they have entries for.
|
||
*/
|
||
export function getMemberOverallReputation(
|
||
memberId: string,
|
||
reputationDoc: ReputationDoc,
|
||
now?: number,
|
||
): number {
|
||
const entries = Object.values(reputationDoc.entries)
|
||
.filter(e => e.memberId === memberId);
|
||
|
||
if (entries.length === 0) return DEFAULT_REPUTATION;
|
||
|
||
const total = entries.reduce((sum, e) => sum + calculateScore(e, now), 0);
|
||
return Math.round(total / entries.length);
|
||
}
|
||
|
||
/**
|
||
* Build a reputation entry key from memberId and skill.
|
||
*/
|
||
export function reputationKey(memberId: string, skill: Skill): string {
|
||
return `${memberId}:${skill}`;
|
||
}
|
||
|
||
/**
|
||
* Create a new rating to add to a reputation entry.
|
||
*/
|
||
export function createRating(fromMemberId: string, score: number): ReputationRating {
|
||
return {
|
||
from: fromMemberId,
|
||
score: Math.max(1, Math.min(5, Math.round(score))),
|
||
timestamp: Date.now(),
|
||
};
|
||
}
|