ai-code-battle/worker-api/src/glicko2.ts
jedarden 9bd4efc935 Start Phase 4: Cloudflare Worker API for match orchestration
- Add worker-api/ with TypeScript + Wrangler setup
- D1 database schema (bots, matches, jobs, rating_history)
- Glicko-2 rating system implementation with unit tests
- Job coordination endpoints (claim, heartbeat, result, fail)
- Bot management endpoints (register, list, update, rotate-key)
- Cron handlers (matchmaker, health checker, stale job reaper)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 07:50:10 -04:00

309 lines
7.6 KiB
TypeScript

// Glicko-2 Rating System Implementation
// Based on: http://www.glicko.net/glicko/glicko2.pdf
import type { Env, Bot, MatchParticipant } from './types';
// Glicko-2 constants
const SCALE = 173.7178; // Rating scale conversion factor
const TAU = 0.5; // System constant (constrains volatility change)
const DEFAULT_RATING = 1500;
const DEFAULT_RD = 350;
const DEFAULT_VOLATILITY = 0.06;
export interface Glicko2Rating {
mu: number; // Mean rating (Glicko-2 scale)
phi: number; // Rating deviation (Glicko-2 scale)
sigma: number; // Volatility
}
/**
* Convert rating to Glicko-2 scale
*/
export function toGlicko2(rating: number, rd: number): Glicko2Rating {
return {
mu: (rating - DEFAULT_RATING) / SCALE,
phi: rd / SCALE,
sigma: DEFAULT_VOLATILITY,
};
}
/**
* Convert from Glicko-2 scale to original scale
*/
export function fromGlicko2(g2: Glicko2Rating): { rating: number; rd: number } {
return {
rating: g2.mu * SCALE + DEFAULT_RATING,
rd: g2.phi * SCALE,
};
}
/**
* Compute g(phi) function
*/
function g(phi: number): number {
return 1 / Math.sqrt(1 + (3 * phi * phi) / (Math.PI * Math.PI));
}
/**
* Compute E(mu, mu_j, phi_j) function
*/
function E(mu: number, mu_j: number, phi_j: number): number {
return 1 / (1 + Math.exp(-g(phi_j) * (mu - mu_j)));
}
/**
* Compute new rating deviation (Step 5/6)
*/
function computeNewPhi(phi: number, v: number): number {
const phiSquared = phi * phi;
const vInverse = 1 / v;
return 1 / Math.sqrt(1 / phiSquared + vInverse);
}
/**
* Iterative algorithm to compute new volatility (Step 5.4)
*/
function computeNewVolatility(
sigma: number,
phi: number,
v: number,
delta: number,
tau: number = TAU
): number {
let a = Math.log(sigma * sigma);
const epsilon = 0.000001;
const f = (x: number): number => {
const expX = Math.exp(x);
const tmp = phi * phi + v + expX;
return (
(expX * (delta * delta - phi * phi - v - expX)) / (2 * tmp * tmp) -
(x - a) / (tau * tau)
);
};
// Set initial bounds
let A = a;
let B: number;
if (delta * delta > phi * phi + v) {
B = Math.log(delta * delta - phi * phi - v);
} else {
let k = 1;
while (f(a - k * tau) < 0) {
k++;
}
B = a - k * tau;
}
// Illinois algorithm
let fA = f(A);
let fB = f(B);
while (Math.abs(B - A) > epsilon) {
const C = A + ((A - B) * fA) / (fB - fA);
const fC = f(C);
if (fC * fB <= 0) {
A = B;
fA = fB;
} else {
fA = fA / 2;
}
B = C;
fB = fC;
}
return Math.exp(A / 2);
}
/**
* Calculate rating updates for a bot after a match
* @param bot The bot whose rating to update
* @param opponents Array of opponent ratings and game outcomes (1=win, 0.5=draw, 0=loss)
* @returns New rating values
*/
export function updateRating(
bot: Bot,
opponents: Array<{
rating: number;
rd: number;
score: number;
}>
): { rating: number; rd: number; volatility: number } {
if (opponents.length === 0) {
// No games played - increase RD over time (rating decay)
const phi = bot.rating_deviation / SCALE;
const newPhi = Math.min(Math.sqrt(phi * phi + bot.rating_volatility * bot.rating_volatility), 350 / SCALE);
return {
rating: bot.rating,
rd: newPhi * SCALE,
volatility: bot.rating_volatility,
};
}
// Convert to Glicko-2 scale
const g2 = toGlicko2(bot.rating, bot.rating_deviation);
g2.sigma = bot.rating_volatility;
// Step 3: Compute v (variance of game outcomes)
let vInverse = 0;
for (const opp of opponents) {
const oppG2 = toGlicko2(opp.rating, opp.rd);
const gPhi = g(oppG2.phi);
const eValue = E(g2.mu, oppG2.mu, oppG2.phi);
vInverse += gPhi * gPhi * eValue * (1 - eValue);
}
const v = 1 / vInverse;
// Step 4: Compute delta (rating improvement)
let deltaSum = 0;
for (const opp of opponents) {
const oppG2 = toGlicko2(opp.rating, opp.rd);
const gPhi = g(oppG2.phi);
const eValue = E(g2.mu, oppG2.mu, oppG2.phi);
deltaSum += gPhi * (opp.score - eValue);
}
const delta = v * deltaSum;
// Step 5: Compute new volatility
const newSigma = computeNewVolatility(g2.sigma, g2.phi, v, delta);
// Step 6: Update phi
const phiStar = Math.sqrt(g2.phi * g2.phi + newSigma * newSigma);
// Step 7: Update phi and mu
const newPhi = 1 / Math.sqrt(1 / (phiStar * phiStar) + 1 / v);
const newMu = g2.mu + newPhi * newPhi * deltaSum;
// Convert back
const result = fromGlicko2({ mu: newMu, phi: newPhi, sigma: newSigma });
return {
rating: result.rating,
rd: result.rd,
volatility: newSigma,
};
}
/**
* Update ratings for all participants in a completed match
*/
export async function updateMatchRatings(
env: Env,
matchId: string,
participants: MatchParticipant[],
winnerId: string | null
): Promise<void> {
// Get all bots involved
const botIds = participants.map((p) => p.bot_id);
const placeholders = botIds.map(() => '?').join(',');
const bots = await env.DB.prepare(
`SELECT * FROM bots WHERE id IN (${placeholders})`
)
.bind(...botIds)
.all<Bot>();
if (!bots.results || bots.results.length !== participants.length) {
throw new Error('Could not find all participant bots');
}
const botMap = new Map(bots.results.map((b) => [b.id, b]));
// Calculate new ratings for each participant
const updates: Array<{
botId: string;
rating: number;
rd: number;
volatility: number;
won: boolean;
}> = [];
for (const participant of participants) {
const bot = botMap.get(participant.bot_id);
if (!bot) continue;
// Build opponent list
const opponents = participants
.filter((p) => p.bot_id !== participant.bot_id)
.map((opp) => {
const oppBot = botMap.get(opp.bot_id)!;
// Score: 1 for win, 0.5 for draw (if no winner), 0 for loss
let score = 0.5;
if (winnerId === participant.bot_id) {
score = 1;
} else if (winnerId === opp.bot_id) {
score = 0;
}
return {
rating: oppBot.rating,
rd: oppBot.rating_deviation,
score,
};
});
const newRating = updateRating(bot, opponents);
const won = winnerId === participant.bot_id;
updates.push({
botId: participant.bot_id,
rating: newRating.rating,
rd: newRating.rd,
volatility: newRating.volatility,
won,
});
}
// Apply updates in a batch
const now = new Date().toISOString();
for (const update of updates) {
// Update bot rating
await env.DB.prepare(
`UPDATE bots SET
rating = ?,
rating_deviation = ?,
rating_volatility = ?,
matches_played = matches_played + 1,
matches_won = matches_won + ?,
updated_at = ?
WHERE id = ?`
)
.bind(
update.rating,
update.rd,
update.volatility,
update.won ? 1 : 0,
now,
update.botId
)
.run();
// Update participant with rating change
await env.DB.prepare(
`UPDATE match_participants SET
rating_after = ?,
rating_deviation_after = ?
WHERE match_id = ? AND bot_id = ?`
)
.bind(update.rating, update.rd, matchId, update.botId)
.run();
// Record rating history
await env.DB.prepare(
`INSERT INTO rating_history (id, bot_id, match_id, rating_before, rating_after, rating_deviation, recorded_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.bind(
crypto.randomUUID(),
update.botId,
matchId,
botMap.get(update.botId)!.rating,
update.rating,
update.rd,
now
)
.run();
}
}