mirror of
https://github.com/williamsjack/pairwise-glicko-ranking.git
synced 2026-07-22 06:44:09 +00:00
Replace Elo engine with Glicko-1 rating system
This commit is contained in:
parent
e6b2e8e76d
commit
a7515a5fb1
9 changed files with 59 additions and 507 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
42
src/domain/rating/GlickoEngine.ts
Normal file
42
src/domain/rating/GlickoEngine.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue