export interface RankableItem {
id: string;
initialRating: number;
currentRating: number;
comparisons: number;
wins: number;
losses: number;
ties: number;
}
export interface ComparisonResult {
itemId1: string;
itemId2: string;
result: 'win' | 'loss' | 'tie';
timestamp: number;
metadata?: any;
}
interface EloRankerConfig {
kFactor?: number;
ratingChangeThreshold?: number;
stableComparisonsThreshold?: number;
minimumComparisons?: number;
defaultInitialRating?: number;
}
class EloRanker {
private items: Map<string, RankableItem>;
private kFactor: number;
private ratingChangeThreshold: number;
private stableComparisonsThreshold: number;
private minimumComparisons: number;
private defaultInitialRating: number;
private recentRatingChanges: Map<string, number[]>;
private sortedItems: RankableItem[];
constructor(
items: RankableItem[],
config: EloRankerConfig = {}
) {
this.items = new Map(items.map(item => [item.id, { ...item }]));
this.kFactor = this.validateConfig(config.kFactor, 8, 64, 32);
this.ratingChangeThreshold = this.validateConfig(config.ratingChangeThreshold, 1, 20, 5);
this.stableComparisonsThreshold = this.validateConfig(config.stableComparisonsThreshold, 5, 50, 10);
this.minimumComparisons = this.validateConfig(config.minimumComparisons, 10, 100, 20);
this.defaultInitialRating = this.validateConfig(config.defaultInitialRating, 1000, 2000, 1500);
this.recentRatingChanges = new Map();
this.sortedItems = this.getSortedItems();
}
private validateConfig(value: number | undefined, min: number, max: number, defaultValue: number): number {
if (value === undefined) return defaultValue;
if (value < min || value > max) {
throw new Error(`Configuration value ${value} is out of range [${min}, ${max}]`);
}
return value;
}
public addItem(id: string, initialRating?: number): void {
if (this.items.has(id)) {
throw new Error(`Item with id ${id} already exists`);
}
const rating = initialRating ?? this.defaultInitialRating;
const newItem: RankableItem = {
id,
initialRating: rating,
currentRating: rating,
comparisons: 0,
wins: 0,
losses: 0,
ties: 0
};
this.items.set(id, newItem);
this.updateSortedItems();
}
public removeItem(id: string): void {
if (!this.items.delete(id)) {
throw new Error(`Item with id ${id} not found`);
}
this.updateSortedItems();
}
private getExpectedScore(ratingA: number, ratingB: number): number {
return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400));
}
private updateRating(itemId: string, opponentRating: number, score: number): void {
const item = this.items.get(itemId)!;
const expectedScore = this.getExpectedScore(item.currentRating, opponentRating);
const oldRating = item.currentRating;
item.currentRating = Math.round(item.currentRating + this.kFactor * (score - expectedScore));
const ratingChange = Math.abs(item.currentRating - oldRating);
const recentChanges = this.recentRatingChanges.get(itemId) || [];
recentChanges.push(ratingChange);
if (recentChanges.length > this.stableComparisonsThreshold) {
recentChanges.shift();
}
this.recentRatingChanges.set(itemId, recentChanges);
}
public addComparisonResult(result: ComparisonResult): void {
const item1 = this.items.get(result.itemId1);
const item2 = this.items.get(result.itemId2);
if (!item1 || !item2) {
throw new Error("One or both items not found");
}
if (item1.id === item2.id) {
throw new Error("Cannot compare an item with itself");
}
let score1, score2;
if (result.result === 'tie') {
score1 = score2 = 0.5;
item1.ties++;
item2.ties++;
} else if (result.result === 'win') {
score1 = 1;
score2 = 0;
item1.wins++;
item2.losses++;
} else {
score1 = 0;
score2 = 1;
item1.losses++;
item2.wins++;
}
this.updateRating(item1.id, item2.currentRating, score1);
this.updateRating(item2.id, item1.currentRating, score2);
item1.comparisons++;
item2.comparisons++;
this.updateSortedItems();
this.cleanupStableItems();
}
public getNextComparison(): [string, string] | null {
const activeItems = Array.from(this.items.values()).filter(item =>
!this.isItemStable(item.id)
);
if (activeItems.length < 2) {
return null;
}
const [item1, item2] = this.getRandomPair(activeItems);
return [item1.id, item2.id];
}
private getRandomPair(items: RankableItem[]): [RankableItem, RankableItem] {
if (items.length < 2) {
throw new Error("Not enough items to form a pair");
}
const idx1 = Math.floor(Math.random() * items.length);
let idx2 = Math.floor(Math.random() * (items.length - 1));
if (idx2 >= idx1) idx2++;
return [items[idx1], items[idx2]];
}
private isItemStable(itemId: string): boolean {
const item = this.items.get(itemId)!;
const recentChanges = this.recentRatingChanges.get(itemId) || [];
return (
item.comparisons >= this.minimumComparisons &&
recentChanges.length >= this.stableComparisonsThreshold &&
recentChanges.every(change => change <= this.ratingChangeThreshold)
);
}
public getProgress(): number {
const totalItems = this.items.size;
const stableItems = Array.from(this.items.values()).filter(item =>
this.isItemStable(item.id)
).length;
return stableItems / totalItems;
}
private getSortedItems(): RankableItem[] {
return Array.from(this.items.values())
.sort((a, b) => b.currentRating - a.currentRating);
}
private updateSortedItems(): void {
this.sortedItems = this.getSortedItems();
}
public getRankings(): RankableItem[] {
return this.sortedItems;
}
public getItemStats(itemId: string): RankableItem {
const item = this.items.get(itemId);
if (!item) {
throw new Error(`Item with id ${itemId} not found`);
}
return { ...item };
}
public getItemCount(): number {
return this.items.size;
}
private cleanupStableItems(): void {
for (const [itemId, item] of this.items) {
if (this.isItemStable(itemId)) {
this.recentRatingChanges.delete(itemId);
}
}
}
}