Expose rating uncertainty in frontmatter

This commit is contained in:
Jack Williams 2026-03-08 00:11:46 +11:00
parent 1f7abe4a7d
commit 42bbe6b289
10 changed files with 62 additions and 23 deletions

View file

@ -1,10 +1,9 @@
import type { TFile } from 'obsidian';
import { DEFAULT_SIGMA } from '../rating/GlickoEngine';
import type { ScoredPlayer } from './InfoGain';
import { pickMaxInfoGainPair } from './InfoGain';
export type RatingStats = { rating: number; sigma?: number };
export type RatingStats = { rating: number; sigma: number };
export function pickNextPairIndices(
files: TFile[],
@ -28,7 +27,7 @@ export function pickNextPairIndices(
const players: ScoredPlayer[] = files.map((f, i) => {
const s = getStats(f);
return { index: i, rating: s.rating, sigma: s.sigma ?? DEFAULT_SIGMA };
return { index: i, rating: s.rating, sigma: s.sigma };
});
const result = pickMaxInfoGainPair(players, rng, { lastPairIndices });

View file

@ -184,7 +184,7 @@ export function computePlaceholders(
if (cohort) {
const allWithSigma = Object.entries(cohort.players)
.filter(([, p]) => p.sigma != null)
.map(([id, p]) => ({ id, sigma: p.sigma! }));
.map(([id, p]) => ({ id, sigma: p.sigma }));
const sessionWithSigma = allWithSigma.filter(({ id }) => uniquePlayerIds.has(id));
@ -221,7 +221,7 @@ export function computePlaceholders(
}
// Leaderboard tables
const buildLeaderboard = (entries: { id: string; rating: number; sigma?: number }[]) => {
const buildLeaderboard = (entries: { id: string; rating: number; sigma: number }[]) => {
if (entries.length === 0) return '';
const rows: string[] = [];
rows.push('| Rank | Note | Rating |');
@ -229,7 +229,7 @@ export function computePlaceholders(
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
const name = idToName(e.id, data.idToPath);
const sigma = e.sigma != null ? ` (σ ${Math.round(e.sigma)})` : '';
const sigma = ` (σ ${Math.round(e.sigma)})`;
rows.push(`| ${i + 1} | ${name} | ${Math.round(e.rating)}${sigma} |`);
}
return rows.join('\n');

View file

@ -188,7 +188,7 @@ export default class GlickoSettingsTab extends PluginSettingTab {
new Setting(containerEl)
.setName('Stability threshold')
.setDesc(
`Sigma value at which the progress bar reaches 100%. Lower values require more matches. Default: ${DEFAULT_SETTINGS.stabilityThreshold}.`,
`Uncertainty value at which the progress bar reaches 100%. Lower values require more matches. Default: ${DEFAULT_SETTINGS.stabilityThreshold}.`,
)
.addSlider((sl) => {
const current =
@ -530,7 +530,7 @@ export default class GlickoSettingsTab extends PluginSettingTab {
newProp?: string;
}> = [];
const keys: PropKey[] = ['rating', 'rank', 'matches', 'wins'];
const keys: PropKey[] = ['rating', 'uncertainty', 'rank', 'matches', 'wins'];
for (const key of keys) {
const oldCfg = oldEffective[key];
const newCfg = newEffective[key];
@ -566,6 +566,8 @@ export default class GlickoSettingsTab extends PluginSettingTab {
for (const [id, rank] of rankMap) map.set(id, rank);
} else if (key === 'rating') {
for (const [id, p] of Object.entries(cohort.players)) map.set(id, Math.round(p.rating));
} else if (key === 'uncertainty') {
for (const [id, p] of Object.entries(cohort.players)) map.set(id, Math.round(p.sigma));
} else if (key === 'matches') {
for (const [id, p] of Object.entries(cohort.players)) map.set(id, p.matches);
} else if (key === 'wins') {

View file

@ -8,6 +8,7 @@ export interface FrontmatterPropertyConfig {
export interface FrontmatterPropertiesSettings {
rating: FrontmatterPropertyConfig;
uncertainty: FrontmatterPropertyConfig;
rank: FrontmatterPropertyConfig;
matches: FrontmatterPropertyConfig;
wins: FrontmatterPropertyConfig;
@ -42,6 +43,7 @@ export const DEFAULT_SETTINGS: GlickoSettings = {
sessionLayout: 'new-tab',
frontmatterProperties: {
rating: { property: 'glickoRating', enabled: false },
uncertainty: { property: 'glickoUncertainty', enabled: false },
rank: { property: 'glickoRank', enabled: false },
matches: { property: 'glickoMatches', enabled: false },
wins: { property: 'glickoWins', enabled: false },
@ -68,6 +70,7 @@ export function effectiveFrontmatterProperties(
): FrontmatterPropertiesSettings {
return {
rating: overrides?.rating ?? base.rating,
uncertainty: overrides?.uncertainty ?? base.uncertainty,
rank: overrides?.rank ?? base.rank,
matches: overrides?.matches ?? base.matches,
wins: overrides?.wins ?? base.wins,

View file

@ -60,6 +60,12 @@ function mergeSettings(raw?: Partial<GlickoSettings>): GlickoSettings {
const out: GlickoSettings = { ...base, ...raw };
// Deep-merge frontmatterProperties so new keys (e.g. uncertainty) get defaults
out.frontmatterProperties = {
...DEFAULT_SETTINGS.frontmatterProperties,
...raw.frontmatterProperties,
};
if (noIdPropertyName) {
out.idPropertyName = 'eloId';
}
@ -183,13 +189,20 @@ export class PluginDataStore {
resetPlayer(cohortKey: string, playerId: string): boolean {
const cohort = this.store.cohorts[cohortKey];
if (!cohort?.players[playerId]) return false;
cohort.players[playerId] = { rating: 1500, matches: 0, wins: 0 };
cohort.players[playerId] = { rating: 1500, matches: 0, wins: 0, sigma: DEFAULT_SIGMA };
return true;
}
ensurePlayer(cohortKey: string, id: string) {
const cohort = (this.store.cohorts[cohortKey] ??= { players: {} } as CohortData);
const player = (cohort.players[id] ??= { rating: 1500, matches: 0, wins: 0 });
const player = (cohort.players[id] ??= {
rating: 1500,
matches: 0,
wins: 0,
sigma: DEFAULT_SIGMA,
});
// Backfill legacy players that predate sigma tracking
if (player.sigma === undefined) (player as { sigma: number }).sigma = DEFAULT_SIGMA;
return { cohort, player };
}
@ -201,8 +214,8 @@ export class PluginDataStore {
): { winnerId?: string; undo: UndoFrame } {
const cohort = (this.store.cohorts[cohortKey] ??= { players: {} });
const a = (cohort.players[aId] ??= { rating: 1500, matches: 0, wins: 0 });
const b = (cohort.players[bId] ??= { rating: 1500, matches: 0, wins: 0 });
const a = (cohort.players[aId] ??= { rating: 1500, matches: 0, wins: 0, sigma: DEFAULT_SIGMA });
const b = (cohort.players[bId] ??= { rating: 1500, matches: 0, wins: 0, sigma: DEFAULT_SIGMA });
const now = Date.now();
@ -220,8 +233,8 @@ export class PluginDataStore {
// accommodate preference drift. This also prevents the stability progress
// bar from regressing after long gaps between sessions.
const cap = this.settings.stabilityThreshold ?? 150;
const preSigmaA = inflateSigma(a.sigma ?? DEFAULT_SIGMA, now - (a.lastMatchAt ?? now), cap);
const preSigmaB = inflateSigma(b.sigma ?? DEFAULT_SIGMA, now - (b.lastMatchAt ?? now), cap);
const preSigmaA = inflateSigma(a.sigma, now - (a.lastMatchAt ?? now), cap);
const preSigmaB = inflateSigma(b.sigma, now - (b.lastMatchAt ?? now), cap);
const sA = result === 'A' ? 1 : result === 'D' ? 0.5 : 0;
const resA = glickoUpdate(a.rating, b.rating, preSigmaA, preSigmaB, sA);
@ -327,7 +340,7 @@ function snapshot(
rating: number,
matches: number,
wins: number,
sigma?: number,
sigma: number,
lastMatchAt?: number,
): PlayerSnapshot {
return { id, rating, matches, wins, sigma, lastMatchAt };

View file

@ -6,7 +6,7 @@ export interface Player {
rating: number;
matches: number;
wins: number;
sigma?: number;
sigma: number;
lastMatchAt?: number;
}
@ -71,7 +71,7 @@ export interface PlayerSnapshot {
rating: number;
matches: number;
wins: number;
sigma?: number;
sigma: number;
lastMatchAt?: number;
}

View file

@ -321,7 +321,7 @@ export default class ArenaSession {
let playedSum = 0;
for (const p of players) {
playedSum += p.sigma ?? DEFAULT_SIGMA;
playedSum += p.sigma;
}
// Files not yet in the player table carry full uncertainty
@ -739,7 +739,7 @@ export default class ArenaSession {
// ---- Matchmaking helpers ----
private getStatsForFile(file: TFile): { rating: number; sigma?: number } {
private getStatsForFile(file: TFile): { rating: number; sigma: number } {
const id = this.idByPath.get(file.path);
const cohort = this.plugin.dataStore.store.cohorts[this.cohortKey];
@ -748,7 +748,7 @@ export default class ArenaSession {
if (p) return { rating: p.rating, sigma: p.sigma };
}
return { rating: 1500 };
return { rating: 1500, sigma: DEFAULT_SIGMA };
}
private pickNextPair() {

View file

@ -106,6 +106,7 @@ export class CohortOptionsModal extends BasePromiseModal<CohortOptionsResult | u
this.working = {
rating: mk('rating'),
uncertainty: mk('uncertainty'),
rank: mk('rank'),
matches: mk('matches'),
wins: mk('wins'),

View file

@ -1,8 +1,14 @@
import type { TextComponent, ToggleComponent } from 'obsidian';
import { Setting } from 'obsidian';
export type FmPropKey = 'rating' | 'rank' | 'matches' | 'wins';
export const FM_PROP_KEYS: readonly FmPropKey[] = ['rating', 'rank', 'matches', 'wins'] as const;
export type FmPropKey = 'rating' | 'uncertainty' | 'rank' | 'matches' | 'wins';
export const FM_PROP_KEYS: readonly FmPropKey[] = [
'rating',
'uncertainty',
'rank',
'matches',
'wins',
] as const;
type Meta = { label: string; desc: string };
@ -11,6 +17,10 @@ const META: Record<FmPropKey, Meta> = {
label: 'Rating',
desc: 'Write the current Glicko rating to this property.',
},
uncertainty: {
label: 'Uncertainty',
desc: 'Write how uncertain the rating is. Starts high and decreases as more comparisons are made.',
},
rank: {
label: 'Rank',
desc: 'Write the cohort rank (1 = highest) to this property.',

View file

@ -7,13 +7,20 @@ import { getNoteId } from './NoteIds';
type PlayerStats = {
rating: number;
uncertainty: number;
matches: number;
wins: number;
rank: number;
};
function anyEnabled(fm: FrontmatterPropertiesSettings): boolean {
return !!fm.rating.enabled || !!fm.rank.enabled || !!fm.matches.enabled || !!fm.wins.enabled;
return (
!!fm.rating.enabled ||
!!fm.uncertainty.enabled ||
!!fm.rank.enabled ||
!!fm.matches.enabled ||
!!fm.wins.enabled
);
}
// Standard competition ranking ("1224" style)
@ -71,6 +78,9 @@ function buildProps(fm: FrontmatterPropertiesSettings, stats: PlayerStats): Reco
if (fm.rating.enabled && fm.rating.property) {
out[fm.rating.property] = Math.round(stats.rating);
}
if (fm.uncertainty.enabled && fm.uncertainty.property) {
out[fm.uncertainty.property] = Math.round(stats.uncertainty);
}
if (fm.rank.enabled && fm.rank.property) {
out[fm.rank.property] = stats.rank;
}
@ -304,6 +314,7 @@ export async function writeFrontmatterStatsForPlayer(
const rankMap = precomputedRankMap;
const props = buildProps(fm, {
rating: p.rating,
uncertainty: p.sigma,
matches: p.matches,
wins: p.wins,
rank: rankMap.get(playerId) ?? rankMap.size,