From a7515a5fb1748f50d252c3988254be2bd75e68ca Mon Sep 17 00:00:00 2001 From: Jack Williams Date: Wed, 4 Mar 2026 00:58:27 +1100 Subject: [PATCH] Replace Elo engine with Glicko-1 rating system --- README.md | 5 +- src/domain/elo/EloEngine.ts | 117 ----------- src/domain/matchmaking/InfoGain.ts | 31 +-- src/domain/matchmaking/Matchmaker.ts | 3 +- src/domain/rating/GlickoEngine.ts | 42 ++++ src/settings/SettingsTab.ts | 277 +-------------------------- src/settings/settings.ts | 51 ----- src/storage/PluginDataStore.ts | 37 +--- src/ui/ArenaSession.ts | 3 +- 9 files changed, 59 insertions(+), 507 deletions(-) delete mode 100644 src/domain/elo/EloEngine.ts create mode 100644 src/domain/rating/GlickoEngine.ts diff --git a/README.md b/README.md index 903b042..03f2de8 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ _Other comparison arena UI options are available in Settings - shown here is **r - Per-cohort stats and rankings can be written to frontmatter - Information-gain matchmaking that automatically picks the most useful pairs to compare - A stability progress bar that shows how close your rankings are to converging -- Advanced convergence heuristics you can tune +- Glicko-1 rating updates - uncertainty (sigma) governs step sizes automatically, so new notes converge fast and experienced notes stay stable - Robust to renames and moves via stable per-note Elo IDs - Cohorts are saved so you can resume ranking sessions, picking up where you left off @@ -164,9 +164,8 @@ Stats are written to just the two notes involved after each match. Rank across t ## Settings overview -- K-factor and winner toasts +- Winner toasts - Where to store Elo IDs: frontmatter (default) or end-of-note comment -- Advanced Elo heuristics (provisional boost, decay, upset boost, draw boost) - Progress bar settings (stability threshold, surprise highlight) - Default frontmatter properties (names and which to write) - Ask for per-cohort overrides when creating a cohort (on by default) diff --git a/src/domain/elo/EloEngine.ts b/src/domain/elo/EloEngine.ts deleted file mode 100644 index 5245130..0000000 --- a/src/domain/elo/EloEngine.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { EloHeuristicsSettings as EloHeuristics } from '../../settings'; -import type { MatchResult } from '../../types'; - -export function expectedScore(rA: number, rB: number): number { - return 1 / (1 + Math.pow(10, (rB - rA) / 400)); -} - -function clamp(v: number, lo: number, hi: number): number { - return Math.max(lo, Math.min(hi, v)); -} - -function kFromMatches(baseK: number, matches: number, decay?: EloHeuristics['decay']): number { - if (!decay?.enabled) return baseK; - - const halfLife = Math.max(1, Math.round(decay.halfLife ?? 200)); - let minK = Math.max(1, Math.round(decay.minK ?? 8)); - minK = Math.min(minK, baseK); - - // Hyperbolic decay with half-life: - // factor = 1 / (1 + matches / halfLife) - const factor = 1 / (1 + matches / halfLife); - const k = baseK * factor; - return Math.max(minK, k); -} - -function applyProvisionalBoost( - k: number, - matches: number, - prov?: EloHeuristics['provisional'], -): number { - if (!prov?.enabled) return k; - const n = Math.max(1, Math.round(prov.matches ?? 10)); - const mult = clamp(prov.multiplier ?? 2.0, 1.0, 5.0); - return matches < n ? k * mult : k; -} - -function applyOutcomeBoosts( - kA: number, - kB: number, - rA: number, - rB: number, - result: MatchResult, - upset?: EloHeuristics['upsetBoost'], - drawGap?: EloHeuristics['drawGapBoost'], -): { kA: number; kB: number } { - const gap = Math.abs(rA - rB); - - // Upset boost: multiply K if the lower-rated note wins by a decent margin. - if (upset?.enabled) { - const th = Math.max(0, Math.round(upset.threshold ?? 200)); - const mult = clamp(upset.multiplier ?? 1.25, 1.0, 3.0); - const underdogAWins = rA < rB && result === 'A'; - const underdogBWins = rB < rA && result === 'B'; - if (gap >= th && (underdogAWins || underdogBWins)) { - kA *= mult; - kB *= mult; - } - } - - // Draw gap boost: a draw with a big gap is informative; move faster. - if (drawGap?.enabled && result === 'D') { - const th = Math.max(0, Math.round(drawGap.threshold ?? 300)); - const mult = clamp(drawGap.multiplier ?? 1.25, 1.0, 3.0); - if (gap >= th) { - kA *= mult; - kB *= mult; - } - } - - return { kA, kB }; -} - -/** - * Advanced Elo update that supports per-player effective K, provisional boosts, - * simple K decay with experience, and upset/draw gap boosts. If all options - * are disabled or omitted, this reduces to classic Elo with constant K. - */ -export function updateElo( - rA: number, - rB: number, - result: MatchResult, - baseK: number, - aMatches: number, - bMatches: number, - heuristics?: EloHeuristics, -): { newA: number; newB: number; kA: number; kB: number; eA: number } { - // Per-player K from decay + provisional boost - let kA = kFromMatches(baseK, aMatches, heuristics?.decay); - let kB = kFromMatches(baseK, bMatches, heuristics?.decay); - - kA = applyProvisionalBoost(kA, aMatches, heuristics?.provisional); - kB = applyProvisionalBoost(kB, bMatches, heuristics?.provisional); - - // Outcome-based multipliers (upsets and big-gap draws) - const boosted = applyOutcomeBoosts( - kA, - kB, - rA, - rB, - result, - heuristics?.upsetBoost, - heuristics?.drawGapBoost, - ); - kA = boosted.kA; - kB = boosted.kB; - - // Classic Elo update but with kA/kB - const eA = expectedScore(rA, rB); - const sA = result === 'A' ? 1 : result === 'D' ? 0.5 : 0; - const sB = 1 - sA; - const eB = 1 - eA; - - const newA = rA + kA * (sA - eA); - const newB = rB + kB * (sB - eB); - - return { newA, newB, kA, kB, eA }; -} diff --git a/src/domain/matchmaking/InfoGain.ts b/src/domain/matchmaking/InfoGain.ts index e97e5d5..c92fe4c 100644 --- a/src/domain/matchmaking/InfoGain.ts +++ b/src/domain/matchmaking/InfoGain.ts @@ -1,14 +1,7 @@ -/** - * Information-gain matchmaker - * - * Tracks per-player uncertainty (sigma) and picks the pair whose comparison - * would most reduce overall ranking uncertainty. - */ +// Information-gain matchmaker +// Picks the pair whose comparison would most reduce overall ranking uncertainty. -import { expectedScore } from '../elo/EloEngine'; - -export const DEFAULT_SIGMA = 350; -export const MIN_SIGMA = 30; +import { expectedScore } from '../rating/GlickoEngine'; export interface ScoredPlayer { index: number; @@ -18,24 +11,6 @@ export interface ScoredPlayer { type Rng = () => number; -// ---- Glicko helpers ---- - -const Q = Math.log(10) / 400; - -// Glicko g-function: dampens the influence of an opponent's uncertainty. -export function gSigma(sigma: number): number { - return 1 / Math.sqrt(1 + (3 * Q * Q * sigma * sigma) / (Math.PI * Math.PI)); -} - -// Outcome-independent uncertainty (sigma) update for one player after a single comparison. -export function updateSigma(rI: number, rJ: number, sigmaI: number, sigmaJ: number): number { - const g = gSigma(sigmaJ); - const E = 1 / (1 + Math.pow(10, (-g * (rI - rJ)) / 400)); - const dSq = 1 / (Q * Q * g * g * E * (1 - E)); - const newSigma = 1 / Math.sqrt(1 / (sigmaI * sigmaI) + 1 / dSq); - return Math.max(MIN_SIGMA, Math.min(DEFAULT_SIGMA, newSigma)); -} - // ---- Information gain ---- // Expected information gain from comparing players i and j. diff --git a/src/domain/matchmaking/Matchmaker.ts b/src/domain/matchmaking/Matchmaker.ts index ea09e72..88cd502 100644 --- a/src/domain/matchmaking/Matchmaker.ts +++ b/src/domain/matchmaking/Matchmaker.ts @@ -1,8 +1,9 @@ import type { TFile } from 'obsidian'; import { pairSig as mkPairSig } from '../../utils/pair'; +import { DEFAULT_SIGMA } from '../rating/GlickoEngine'; import type { ScoredPlayer } from './InfoGain'; -import { DEFAULT_SIGMA, pickMaxInfoGainPair } from './InfoGain'; +import { pickMaxInfoGainPair } from './InfoGain'; export type RatingStats = { rating: number; sigma?: number }; diff --git a/src/domain/rating/GlickoEngine.ts b/src/domain/rating/GlickoEngine.ts new file mode 100644 index 0000000..4f50f26 --- /dev/null +++ b/src/domain/rating/GlickoEngine.ts @@ -0,0 +1,42 @@ +export const DEFAULT_SIGMA = 350; +export const MIN_SIGMA = 30; + +const Q = Math.log(10) / 400; + +export function expectedScore(rA: number, rB: number): number { + return 1 / (1 + Math.pow(10, (rB - rA) / 400)); +} + +// Glicko g-function: dampens the influence of an opponent's uncertainty. +export function gSigma(sigma: number): number { + return 1 / Math.sqrt(1 + (3 * Q * Q * sigma * sigma) / (Math.PI * Math.PI)); +} + +/** + * Glicko-1 rating + sigma update for one player after a single comparison. + * + * Computes both the new rating and new sigma in one pass, sharing the + * intermediates (g, E, d^2). Sigma alone governs step size - no K-factor needed. + * + * @param score 1 = win, 0.5 = draw, 0 = loss (from i's perspective) + */ +export function glickoUpdate( + rI: number, + rJ: number, + sigmaI: number, + sigmaJ: number, + score: number, +): { newRating: number; newSigma: number } { + const g = gSigma(sigmaJ); + const E = 1 / (1 + Math.pow(10, (-g * (rI - rJ)) / 400)); + const dSq = 1 / (Q * Q * g * g * E * (1 - E)); + + const sigmaISq = sigmaI * sigmaI; + const newRating = rI + (Q * sigmaISq * g * (score - E)) / (1 + (Q * Q * sigmaISq) / dSq); + const newSigma = 1 / Math.sqrt(1 / sigmaISq + 1 / dSq); + + return { + newRating, + newSigma: Math.max(MIN_SIGMA, Math.min(DEFAULT_SIGMA, newSigma)), + }; +} diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts index ffbb042..f1ebbbb 100644 --- a/src/settings/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -1,4 +1,4 @@ -import type { App, SliderComponent } from 'obsidian'; +import type { App } from 'obsidian'; import { Notice, PluginSettingTab, setIcon, Setting } from 'obsidian'; import { prettyCohortDefinition, resolveFilesForCohort } from '../domain/cohort/CohortResolver'; @@ -78,41 +78,7 @@ export default class EloSettingsTab extends PluginSettingTab { const { containerEl } = this; containerEl.empty(); - const minK = 8; - const maxK = 64; - const stepK = 1; - let initialK = this.plugin.settings.kFactor; - if (!Number.isFinite(initialK)) initialK = 24; - initialK = Math.min(maxK, Math.max(minK, Math.round(initialK))); - - let minKSlider: SliderComponent; - // General - new Setting(containerEl) - .setName('K-factor') - .setDesc( - `Adjusts how quickly ratings move (larger K = faster changes). Typical values 16–40. Default: ${DEFAULT_SETTINGS.kFactor}.`, - ) - .addSlider((s) => { - s.setLimits(minK, maxK, stepK) - .setValue(initialK) - .setDynamicTooltip() - .onChange(async (value) => { - this.plugin.settings.kFactor = value; - - // UI-level: clamp the "Minimum K" slider's max to the current base K - // and clamp the stored minK if it now exceeds base K. - const dec = this.plugin.settings.heuristics.decay; - if (dec.minK > value) dec.minK = value; - - // Update the UI control if it exists - minKSlider.setLimits(4, value, 1); - if (minKSlider.getValue() > value) minKSlider.setValue(value); - - await this.plugin.saveSettings(); - }); - }); - new Setting(containerEl) .setName('Show win/draw notices') .setDesc( @@ -362,247 +328,6 @@ export default class EloSettingsTab extends PluginSettingTab { }); } - // Convergence heuristics accordion - const hs = this.plugin.settings.heuristics; - const defaults = DEFAULT_SETTINGS.heuristics; - - const adv = containerEl.createEl('details', { cls: 'elo-settings-accordion' }); - adv.open = false; - - adv.createEl('summary', { text: 'Convergence heuristics' }); - const advBody = adv.createEl('div', { cls: 'elo-settings-body' }); - advBody.createEl('p', { - text: 'Optional tweaks that help new notes stabilise quickly and move ratings faster when results are more informative.', - }); - - // Provisional K boost - new Setting(advBody).setName('Provisional K boost').setHeading(); - advBody.createEl('p', { - text: - "Use a higher K-factor for a note's first N matches to place new notes quickly. " + - 'This is applied per note, per cohort.', - }); - - let provMatchesSlider: SliderComponent; - let provMultSlider: SliderComponent; - - new Setting(advBody) - .setName('Enable provisional boost') - .setDesc(`Default: ${defaults.provisional.enabled ? 'On' : 'Off'}.`) - .addToggle((t) => - t.setValue(hs.provisional.enabled).onChange(async (v) => { - hs.provisional.enabled = v; - await this.plugin.saveSettings(); - provMatchesSlider.setDisabled(!v); - provMultSlider.setDisabled(!v); - }), - ); - - new Setting(advBody) - .setName('Provisional period (matches)') - .setDesc( - `Applies while the note has played fewer than N matches. Default: ${defaults.provisional.matches}.`, - ) - .addSlider((sl) => { - provMatchesSlider = sl; - sl.setLimits(1, 30, 1) - .setValue(Math.max(1, Math.min(30, hs.provisional.matches))) - .setDynamicTooltip() - .onChange(async (value) => { - hs.provisional.matches = Math.round(value); - await this.plugin.saveSettings(); - }) - .setDisabled(!hs.provisional.enabled); - }); - - new Setting(advBody) - .setName('Provisional K multiplier') - .setDesc( - `Multiplier on K during the provisional period. 1.0 disables the boost. Default: ${defaults.provisional.multiplier}.`, - ) - .addSlider((sl) => { - provMultSlider = sl; - sl.setLimits(1.0, 3.0, 0.05) - .setValue(Math.max(1.0, Math.min(3.0, hs.provisional.multiplier))) - .setDynamicTooltip() - .onChange(async (value) => { - hs.provisional.multiplier = Math.max(1.0, Math.min(3.0, value)); - await this.plugin.saveSettings(); - }) - .setDisabled(!hs.provisional.enabled); - }); - - // Decay with experience - new Setting(advBody).setName('Decay K with experience').setHeading(); - advBody.createEl('p', { - text: - 'Gradually reduce K as a note plays more matches, stabilising mature ratings. ' + - 'Effective K follows k = baseK / (1 + matches / halfLife).', - }); - - let halfSlider: SliderComponent; - - new Setting(advBody) - .setName('Enable K decay') - .setDesc(`Default: ${defaults.decay.enabled ? 'On' : 'Off'}.`) - .addToggle((t) => - t.setValue(hs.decay.enabled).onChange(async (v) => { - hs.decay.enabled = v; - await this.plugin.saveSettings(); - halfSlider.setDisabled(!v); - minKSlider.setDisabled(!v); - }), - ); - - new Setting(advBody) - .setName('Half-life (matches)') - .setDesc( - `At this many matches, the effective K is half of your base K. Default: ${defaults.decay.halfLife}.`, - ) - .addSlider((sl) => { - halfSlider = sl; - sl.setLimits(10, 500, 5) - .setValue(Math.max(10, Math.min(500, hs.decay.halfLife))) - .setDynamicTooltip() - .onChange(async (value) => { - hs.decay.halfLife = Math.round(value); - await this.plugin.saveSettings(); - }) - .setDisabled(!hs.decay.enabled); - }); - - new Setting(advBody) - .setName('Minimum K') - .setDesc( - `Lower bound on K for very experienced notes. Tip: keep this ≤ your base K (currently ${this.plugin.settings.kFactor}). Default: ${defaults.decay.minK}.`, - ) - .addSlider((sl) => { - // Clamp the slider's max to the current base K on creation - minKSlider = sl; - sl.setLimits(4, this.plugin.settings.kFactor, 1) - .setValue(Math.max(4, Math.min(this.plugin.settings.kFactor, hs.decay.minK))) - .setDynamicTooltip() - .onChange(async (v) => { - hs.decay.minK = Math.round(Math.max(4, Math.min(this.plugin.settings.kFactor, v))); - await this.plugin.saveSettings(); - }) - .setDisabled(!hs.decay.enabled); - }); - - // Upset boost - new Setting(advBody).setName('Upset boost').setHeading(); - advBody.createEl('p', { - text: - 'Increase K when a significantly lower-rated note wins. This helps ratings correct faster after surprises. ' + - 'Both sides receive the multiplier for the qualifying match.', - }); - - let upsetGapSlider: SliderComponent; - let upsetMultSlider: SliderComponent; - - new Setting(advBody) - .setName('Enable upset boost') - .setDesc(`Default: ${defaults.upsetBoost.enabled ? 'On' : 'Off'}.`) - .addToggle((t) => - t.setValue(hs.upsetBoost.enabled).onChange(async (v) => { - hs.upsetBoost.enabled = v; - await this.plugin.saveSettings(); - upsetGapSlider.setDisabled(!v); - upsetMultSlider.setDisabled(!v); - }), - ); - - new Setting(advBody) - .setName('Upset gap threshold') - .setDesc( - `Minimum pre-match rating gap for an underdog win to qualify. Default: ${defaults.upsetBoost.threshold}.`, - ) - .addSlider((sl) => { - upsetGapSlider = sl; - sl.setLimits(50, 600, 25) - .setValue(Math.max(0, Math.min(600, hs.upsetBoost.threshold))) - .setDynamicTooltip() - .onChange(async (value) => { - hs.upsetBoost.threshold = Math.round(value); - await this.plugin.saveSettings(); - }) - .setDisabled(!hs.upsetBoost.enabled); - }); - - new Setting(advBody) - .setName('Upset K multiplier') - .setDesc( - `Multiplier applied when the underdog wins. Default: ${defaults.upsetBoost.multiplier}.`, - ) - .addSlider((sl) => { - upsetMultSlider = sl; - sl.setLimits(1.0, 3, 0.05) - .setValue(Math.max(1.0, Math.min(3, hs.upsetBoost.multiplier))) - .setDynamicTooltip() - .onChange(async (value) => { - hs.upsetBoost.multiplier = Math.max(1.0, Math.min(3.0, value)); - await this.plugin.saveSettings(); - }) - .setDisabled(!hs.upsetBoost.enabled); - }); - - // Big-gap draw boost - new Setting(advBody).setName('Big-gap draw boost').setHeading(); - advBody.createEl('p', { - text: - 'Increase K for draws across a large rating gap. A draw here suggests the ratings should move. ' + - 'Both sides receive the multiplier for the qualifying draw.', - }); - - let drawGapSlider: SliderComponent; - let drawMultSlider: SliderComponent; - - new Setting(advBody) - .setName('Enable big-gap draw boost') - .setDesc(`Default: ${defaults.drawGapBoost.enabled ? 'On' : 'Off'}.`) - .addToggle((t) => - t.setValue(hs.drawGapBoost.enabled).onChange(async (v) => { - hs.drawGapBoost.enabled = v; - await this.plugin.saveSettings(); - drawGapSlider.setDisabled(!v); - drawMultSlider.setDisabled(!v); - }), - ); - - new Setting(advBody) - .setName('Draw gap threshold') - .setDesc( - `Minimum pre-match rating gap for a draw to qualify. Default: ${defaults.drawGapBoost.threshold}.`, - ) - .addSlider((sl) => { - drawGapSlider = sl; - sl.setLimits(50, 800, 25) - .setValue(Math.max(0, Math.min(800, hs.drawGapBoost.threshold))) - .setDynamicTooltip() - .onChange(async (value) => { - hs.drawGapBoost.threshold = Math.round(value); - await this.plugin.saveSettings(); - }) - .setDisabled(!hs.drawGapBoost.enabled); - }); - - new Setting(advBody) - .setName('Draw K multiplier') - .setDesc( - `Multiplier applied to both sides for qualifying draws. Default: ${defaults.drawGapBoost.multiplier}.`, - ) - .addSlider((sl) => { - drawMultSlider = sl; - sl.setLimits(1.0, 3.0, 0.05) - .setValue(Math.max(1.0, Math.min(3.0, hs.drawGapBoost.multiplier))) - .setDynamicTooltip() - .onChange(async (value) => { - hs.drawGapBoost.multiplier = Math.max(1.0, Math.min(3.0, value)); - await this.plugin.saveSettings(); - }) - .setDisabled(!hs.drawGapBoost.enabled); - }); - // Stability progress bar accordion const stabAcc = containerEl.createEl('details', { cls: 'elo-settings-accordion' }); stabAcc.open = false; diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 8842e20..05ec233 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -13,45 +13,18 @@ export interface FrontmatterPropertiesSettings { wins: FrontmatterPropertyConfig; } -// Heuristics config -export interface EloHeuristicsSettings { - provisional: { - enabled: boolean; - matches: number; // First N matches use a higher K - multiplier: number; // Multiplier on K during provisional phase - }; - decay: { - enabled: boolean; - halfLife: number; // Matches at which K is halved (via 1/(1+m/halfLife)) - minK: number; // Lower bound on K - }; - upsetBoost: { - enabled: boolean; - threshold: number; // Rating gap that qualifies as an upset - multiplier: number; // K multiplier for upsets - }; - drawGapBoost: { - enabled: boolean; - threshold: number; // Rating gap where a draw is considered highly informative - multiplier: number; // K multiplier for those draws - }; -} - export interface EloSettings { - kFactor: number; showToasts: boolean; eloIdLocation: EloIdLocation; sessionLayout: SessionLayoutMode; frontmatterProperties: FrontmatterPropertiesSettings; askForOverridesOnCohortCreation: boolean; - heuristics: EloHeuristicsSettings; stabilityThreshold: number; surpriseJitter: boolean; templatesFolderPath: string; } export const DEFAULT_SETTINGS: EloSettings = { - kFactor: 24, showToasts: true, eloIdLocation: 'frontmatter', sessionLayout: 'new-tab', @@ -63,30 +36,6 @@ export const DEFAULT_SETTINGS: EloSettings = { }, askForOverridesOnCohortCreation: true, - // Modest defaults; provisional + upset + big-gap draw boosts on by default. - heuristics: { - provisional: { - enabled: true, - matches: 10, - multiplier: 2.0, - }, - decay: { - enabled: false, - halfLife: 200, - minK: 8, - }, - upsetBoost: { - enabled: true, - threshold: 200, - multiplier: 1.25, - }, - drawGapBoost: { - enabled: true, - threshold: 300, - multiplier: 1.25, - }, - }, - stabilityThreshold: 150, surpriseJitter: true, diff --git a/src/storage/PluginDataStore.ts b/src/storage/PluginDataStore.ts index b1be9b2..279a0a9 100644 --- a/src/storage/PluginDataStore.ts +++ b/src/storage/PluginDataStore.ts @@ -1,7 +1,6 @@ import type { Plugin } from 'obsidian'; -import { updateElo } from '../domain/elo/EloEngine'; -import { DEFAULT_SIGMA, updateSigma } from '../domain/matchmaking/InfoGain'; +import { DEFAULT_SIGMA, glickoUpdate } from '../domain/rating/GlickoEngine'; import type { EloSettings, SessionLayoutMode } from '../settings'; import { DEFAULT_SETTINGS } from '../settings'; import type { @@ -62,17 +61,6 @@ function mergeSettings(raw?: Partial): EloSettings { out.templatesFolderPath = normaliseTemplatesFolderPath(raw.templatesFolderPath); - if (raw.heuristics) { - out.heuristics = { - ...base.heuristics, - ...raw.heuristics, - provisional: { ...base.heuristics.provisional, ...raw.heuristics.provisional }, - decay: { ...base.heuristics.decay, ...raw.heuristics.decay }, - upsetBoost: { ...base.heuristics.upsetBoost, ...raw.heuristics.upsetBoost }, - drawGapBoost: { ...base.heuristics.drawGapBoost, ...raw.heuristics.drawGapBoost }, - }; - } - return out; } @@ -200,32 +188,23 @@ export class PluginDataStore { ts: Date.now(), }; - const preRatingA = a.rating; - const preRatingB = b.rating; const preSigmaA = a.sigma ?? DEFAULT_SIGMA; const preSigmaB = b.sigma ?? DEFAULT_SIGMA; - const hs = this.settings.heuristics; + const sA = result === 'A' ? 1 : result === 'D' ? 0.5 : 0; + const resA = glickoUpdate(a.rating, b.rating, preSigmaA, preSigmaB, sA); + const resB = glickoUpdate(b.rating, a.rating, preSigmaB, preSigmaA, 1 - sA); - const { newA, newB } = updateElo( - a.rating, - b.rating, - result, - this.settings.kFactor, - a.matches, - b.matches, - hs, - ); - a.rating = newA; - b.rating = newB; + a.rating = resA.newRating; + b.rating = resB.newRating; a.matches += 1; b.matches += 1; if (result === 'A') a.wins += 1; if (result === 'B') b.wins += 1; - a.sigma = updateSigma(preRatingA, preRatingB, preSigmaA, preSigmaB); - b.sigma = updateSigma(preRatingB, preRatingA, preSigmaB, preSigmaA); + a.sigma = resA.newSigma; + b.sigma = resB.newSigma; const winnerId = result === 'A' ? aId : result === 'B' ? bId : undefined; return { winnerId, undo }; diff --git a/src/ui/ArenaSession.ts b/src/ui/ArenaSession.ts index b96387a..d21f98c 100644 --- a/src/ui/ArenaSession.ts +++ b/src/ui/ArenaSession.ts @@ -1,9 +1,8 @@ import type { App, EventRef, WorkspaceLeaf } from 'obsidian'; import { MarkdownView, Notice, Platform, TFile } from 'obsidian'; -import { expectedScore } from '../domain/elo/EloEngine'; -import { DEFAULT_SIGMA } from '../domain/matchmaking/InfoGain'; import { pickNextPairIndices } from '../domain/matchmaking/Matchmaker'; +import { DEFAULT_SIGMA, expectedScore } from '../domain/rating/GlickoEngine'; import type EloPlugin from '../main'; import type { FrontmatterPropertiesSettings } from '../settings'; import { effectiveFrontmatterProperties } from '../settings';