/** * 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(), }; }