Replace Elo engine with Glicko-1 rating system

This commit is contained in:
Jack Williams 2026-03-04 00:58:27 +11:00
parent e6b2e8e76d
commit a7515a5fb1
9 changed files with 59 additions and 507 deletions

View file

@ -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)

View file

@ -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 };
}

View file

@ -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.

View file

@ -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 };

View file

@ -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)),
};
}

View file

@ -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 1640. 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;

View file

@ -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,

View file

@ -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>): 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 };

View file

@ -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';