feat(game): plumb ConvergenceOptions through to run_chain

Game and OwnedGame gain a convergence: ConvergenceOptions field set at
construction. Game::{ranked,scored} forward options.convergence into
OwnedGame::{new,new_scored} (previously dropped on the floor).
{ranked,scored}_with_arena take it as a parameter. run_chain reads
self.convergence.{epsilon, max_iter, alpha} instead of hardcoded
1e-6 / 10 / undamped. DiffFactor::propagate gains an alpha parameter
and dispatches into Trunc/MarginFactor::propagate_with_alpha.

In-tree callsites in src/time_slice.rs and src/history.rs pass
ConvergenceOptions::default(). Pre-existing T2 fallout in tests,
benches, and the atp example (struct literals missing the new alpha
field) is fixed by adding alpha: 1.0 so the workspace builds clean.
Default alpha is 1.0, so all 96 lib + 27 integration test goldens
remain bit-equal.
This commit is contained in:
2026-05-08 15:10:35 +02:00
parent aacaa60baa
commit 0705986929
8 changed files with 138 additions and 32 deletions
+1
View File
@@ -51,6 +51,7 @@ fn build_history_1v1(
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
alpha: 1.0,
})
.build();
+1
View File
@@ -48,6 +48,7 @@ fn main() {
.convergence(trueskill_tt::ConvergenceOptions {
max_iter: 10,
epsilon: 0.01,
alpha: 1.0,
})
.build();
+94 -16
View File
@@ -44,11 +44,14 @@ impl DiffFactor {
}
}
pub(crate) fn propagate(&mut self, vars: &mut crate::factor::VarStore) -> (f64, f64) {
use crate::factor::Factor;
pub(crate) fn propagate(
&mut self,
vars: &mut crate::factor::VarStore,
alpha: f64,
) -> (f64, f64) {
match self {
Self::Trunc(f) => f.propagate(vars),
Self::Margin(f) => f.propagate(vars),
Self::Trunc(f) => f.propagate_with_alpha(vars, alpha),
Self::Margin(f) => f.propagate_with_alpha(vars, alpha),
}
}
}
@@ -87,6 +90,7 @@ pub struct OwnedGame<T: Time, D: Drift<T>> {
result: Vec<f64>,
weights: Vec<Vec<f64>>,
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64,
}
@@ -97,9 +101,17 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
result: Vec<f64>,
weights: Vec<Vec<f64>>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
) -> Self {
let mut arena = ScratchArena::new();
let g = Game::ranked_with_arena(teams.clone(), &result, &weights, p_draw, &mut arena);
let g = Game::ranked_with_arena(
teams.clone(),
&result,
&weights,
p_draw,
convergence,
&mut arena,
);
let likelihoods = g.likelihoods;
let evidence = g.evidence;
Self {
@@ -107,6 +119,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
result,
weights,
p_draw,
convergence,
likelihoods,
evidence,
}
@@ -117,9 +130,17 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
scores: Vec<f64>,
weights: Vec<Vec<f64>>,
score_sigma: f64,
convergence: crate::ConvergenceOptions,
) -> Self {
let mut arena = ScratchArena::new();
let g = Game::scored_with_arena(teams.clone(), &scores, &weights, score_sigma, &mut arena);
let g = Game::scored_with_arena(
teams.clone(),
&scores,
&weights,
score_sigma,
convergence,
&mut arena,
);
let likelihoods = g.likelihoods;
let evidence = g.evidence;
Self {
@@ -127,6 +148,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
result: scores,
weights,
p_draw: 0.0,
convergence,
likelihoods,
evidence,
}
@@ -151,6 +173,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
result: &'a [f64],
weights: &'a [Vec<f64>],
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64,
}
@@ -161,6 +184,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
result: &'a [f64],
weights: &'a [Vec<f64>],
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) -> Self {
debug_assert!(
@@ -186,12 +210,17 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
},
"draw must be > 0.0 if there are teams with draw"
);
debug_assert!(
convergence.alpha > 0.0 && convergence.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0]"
);
let mut this = Self {
teams,
result,
weights,
p_draw,
convergence,
likelihoods: Vec::new(),
evidence: 0.0,
};
@@ -205,6 +234,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
scores: &'a [f64],
weights: &'a [Vec<f64>],
score_sigma: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) -> Self {
debug_assert!(
@@ -219,12 +249,17 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
"weights must have the same dimensions as teams"
);
debug_assert!(score_sigma > 0.0, "score_sigma must be positive");
debug_assert!(
convergence.alpha > 0.0 && convergence.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0]"
);
let mut this = Self {
teams,
result: scores,
weights,
p_draw: 0.0,
convergence,
likelihoods: Vec::new(),
evidence: 0.0,
};
@@ -239,6 +274,10 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
{
arena.reset();
let alpha = self.convergence.alpha;
let epsilon = self.convergence.epsilon;
let max_iter = self.convergence.max_iter;
let n_teams = self.teams.len();
arena.sort_buf.extend(0..n_teams);
@@ -267,7 +306,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
let mut step = (f64::INFINITY, f64::INFINITY);
let mut iter = 0;
while tuple_gt(step, 1e-6) && iter < 10 {
while tuple_gt(step, epsilon) && iter < max_iter {
step = (0.0_f64, 0.0_f64);
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
@@ -275,7 +314,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars);
let d = lf.propagate(&mut arena.vars, alpha);
step = tuple_max(step, d);
let new_ll = pw - lf.msg();
@@ -289,7 +328,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars);
let d = lf.propagate(&mut arena.vars, alpha);
step = tuple_max(step, d);
let new_lw = pl + lf.msg();
@@ -305,7 +344,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
- (arena.team_prior[1] * arena.lhood_win[1]);
arena.vars.set(links[0].diff(), raw * links[0].msg());
links[0].propagate(&mut arena.vars);
links[0].propagate(&mut arena.vars, alpha);
}
// Boundary updates: close the chain at both ends.
@@ -429,7 +468,13 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
Ok(OwnedGame::new(teams_owned, result, weights, options.p_draw))
Ok(OwnedGame::new(
teams_owned,
result,
weights,
options.p_draw,
options.convergence,
))
}
pub fn scored(
@@ -465,6 +510,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
scores,
weights,
options.score_sigma,
options.convergence,
))
}
@@ -526,6 +572,7 @@ mod tests {
&[0.0, 1.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -553,6 +600,7 @@ mod tests {
&[0.0, 1.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -572,6 +620,7 @@ mod tests {
&[0.0, 1.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
@@ -605,6 +654,7 @@ mod tests {
&[1.0, 2.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -621,6 +671,7 @@ mod tests {
&[2.0, 1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -632,7 +683,14 @@ mod tests {
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena(teams, &[1.0, 2.0, 0.0], &w, 0.5, &mut ScratchArena::new());
let g = Game::ranked_with_arena(
teams,
&[1.0, 2.0, 0.0],
&w,
0.5,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
let a = p[0][0];
@@ -664,6 +722,7 @@ mod tests {
&[0.0, 0.0],
&w,
0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -691,6 +750,7 @@ mod tests {
&[0.0, 0.0],
&w,
0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -726,6 +786,7 @@ mod tests {
&[0.0, 0.0, 0.0],
&w,
0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -762,6 +823,7 @@ mod tests {
&[0.0, 0.0, 0.0],
&w,
0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -813,6 +875,7 @@ mod tests {
&[1.0, 0.0, 0.0],
&w,
0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -846,6 +909,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -870,6 +934,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -894,6 +959,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -921,6 +987,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -948,6 +1015,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -967,8 +1035,8 @@ mod tests {
let mut t = DiffFactor::Trunc(TruncFactor::new(dt, 0.0, false));
let mut m = DiffFactor::Margin(MarginFactor::new(dm, 5.0, 1.0));
let _ = t.propagate(&mut vars);
let _ = m.propagate(&mut vars);
let _ = t.propagate(&mut vars, 1.0);
let _ = m.propagate(&mut vars, 1.0);
// Smoke: both diffs got written; their msgs are non-N_INF.
assert!(t.msg().pi() > 0.0);
@@ -989,7 +1057,11 @@ mod tests {
let weights = [vec![1.0], vec![1.0]];
let mut arena = ScratchArena::new();
let g = Game::scored_with_arena(
teams, &result, &weights, 1.0, // score_sigma
teams,
&result,
&weights,
1.0,
crate::ConvergenceOptions::default(),
&mut arena,
);
let p = g.posteriors();
@@ -1008,7 +1080,8 @@ mod tests {
vec![vec![prior], vec![prior]],
&result,
&weights,
0.1, // tighter score_sigma
0.1,
crate::ConvergenceOptions::default(),
&mut arena2,
);
let p_tight = g_tight.posteriors();
@@ -1116,6 +1189,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -1150,6 +1224,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -1184,6 +1259,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
@@ -1222,6 +1298,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let post_2vs1 = g.posteriors();
@@ -1235,6 +1312,7 @@ mod tests {
&[1.0, 0.0],
&w,
0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors();
+1
View File
@@ -838,6 +838,7 @@ mod tests {
&[0.0, 1.0],
&w,
P_DRAW,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
)
.posteriors();
+38 -16
View File
@@ -138,12 +138,22 @@ impl Event {
let teams = self.within_priors(false, false, skills, agents);
let result = self.outputs();
let g = match self.kind {
EventKind::Ranked => {
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, arena)
}
EventKind::Scored { score_sigma } => {
Game::scored_with_arena(teams, &result, &self.weights, score_sigma, arena)
}
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&self.weights,
p_draw,
crate::ConvergenceOptions::default(),
arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&self.weights,
score_sigma,
crate::ConvergenceOptions::default(),
arena,
),
};
for (t, team) in self.teams.iter_mut().enumerate() {
@@ -322,6 +332,7 @@ impl<T: Time> TimeSlice<T> {
&result,
&event.weights,
self.p_draw,
crate::ConvergenceOptions::default(),
&mut self.arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
@@ -329,6 +340,7 @@ impl<T: Time> TimeSlice<T> {
&result,
&event.weights,
score_sigma,
crate::ConvergenceOptions::default(),
&mut self.arena,
),
};
@@ -504,16 +516,26 @@ impl<T: Time> TimeSlice<T> {
let teams = event.within_priors(online, forward, &self.skills, agents);
let result = event.outputs();
match event.kind {
EventKind::Ranked => {
Game::ranked_with_arena(teams, &result, &event.weights, self.p_draw, arena)
.evidence
.ln()
}
EventKind::Scored { score_sigma } => {
Game::scored_with_arena(teams, &result, &event.weights, score_sigma, arena)
.evidence
.ln()
}
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
crate::ConvergenceOptions::default(),
arena,
)
.evidence
.ln(),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
crate::ConvergenceOptions::default(),
arena,
)
.evidence
.ln(),
}
};
+1
View File
@@ -15,6 +15,7 @@ fn add_events_bulk_via_iter() {
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
alpha: 1.0,
})
.build();
+1
View File
@@ -16,6 +16,7 @@ fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
alpha: 1.0,
})
.build();
+1
View File
@@ -10,6 +10,7 @@ fn record_winner_builds_history() {
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
alpha: 1.0,
})
.build();