refactor: unify convergence defaults, validate builders, clear dead code

Convergence configuration had two disagreeing sources of truth and one
misleading report:

- `EpsilonOrMax::default()` capped at 10 iterations while
  `ConvergenceOptions::default()` allowed 30, and which applied depended on
  whether inference went through `run_chain` or a `Schedule`. The schedule
  default now derives from `ConvergenceOptions`.
- A graph with no iterating factors reported `converged: false` with an
  infinite step, despite being at its fixed point after the setup pass. It
  now reports converged with a zero step.
- `TimeSlice::iterate_to_convergence` hard-coded an epsilon and a
  20-iteration cap matching neither. It reads `self.convergence` and is
  scoped to `#[cfg(test)]`, which is all it was ever used by.

`HistoryBuilder::p_draw` and `::convergence` now validate their arguments
like `score_sigma` already did, instead of accepting a negative `p_draw` or
an `alpha` of zero — the latter leaves every EP update unapplied, so
inference silently returns the priors.

Removing the `#[allow(dead_code)]` masks let the compiler report what they
were hiding: four `OwnedGame` fields that were stored and never read, two
`ColorGroups` helpers and three `SkillStore` helpers used only by tests, and
`iterate_to_convergence` above. Test-only items are now `#[cfg(test)]` and
the unread fields are gone.

Also exported `HistoryBuilder`, which was public but unreachable — callers
could chain `History::builder()` but could not name the type — and added
`Rating::{prior, beta, drift}` and `Index::get`, so handles the API hands
out can be read back.

Two goldens moved, both convergence residuals rather than exact values:
`iterate_to_convergence` now runs to 30 iterations instead of 20, landing
nearer the symmetric truth of 25.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnsaJg74eNSva3PJjK2eej
This commit is contained in:
2026-08-04 22:01:49 +02:00
co-authored by Claude Opus 5
parent 355cdb7e05
commit 6030dc78de
8 changed files with 156 additions and 61 deletions
+8 -9
View File
@@ -26,23 +26,22 @@ pub(crate) struct ColorGroups {
} }
impl ColorGroups { impl ColorGroups {
#[allow(dead_code)]
pub(crate) fn new() -> Self { pub(crate) fn new() -> Self {
Self::default() Self::default()
} }
#[allow(dead_code)]
pub(crate) fn n_colors(&self) -> usize {
self.groups.len()
}
#[allow(dead_code)]
pub(crate) fn is_empty(&self) -> bool { pub(crate) fn is_empty(&self) -> bool {
self.groups.is_empty() self.groups.is_empty()
} }
/// Total event count across all colors. /// Number of distinct colors in the partition. Test-only.
#[allow(dead_code)] #[cfg(test)]
pub(crate) fn n_colors(&self) -> usize {
self.groups.len()
}
/// Total event count across all colors. Test-only.
#[cfg(test)]
pub(crate) fn total_events(&self) -> usize { pub(crate) fn total_events(&self) -> usize {
self.groups.iter().map(|g| g.len()).sum() self.groups.iter().map(|g| g.len()).sum()
} }
+7 -23
View File
@@ -88,16 +88,12 @@ impl Default for GameOptions {
/// Owned variant of `Game` returned by public constructors. /// Owned variant of `Game` returned by public constructors.
/// ///
/// Unlike `Game<'a, T, D>` (which borrows its result/weights slices from /// Unlike `Game<'a, T, D>` (which borrows its result/weights slices from
/// History's internal state), `OwnedGame<T, D>` owns its inputs so it can /// History's internal state), `OwnedGame<T, D>` owns the team ratings, so it
/// be returned freely from public constructors. /// can be returned freely from public constructors. The inference inputs
/// themselves are not retained — nothing reads them back.
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)]
pub struct OwnedGame<T: Time, D: Drift<T>> { pub struct OwnedGame<T: Time, D: Drift<T>> {
teams: Vec<Vec<Rating<T, D>>>, teams: Vec<Vec<Rating<T, D>>>,
result: Vec<f64>,
weights: Vec<Vec<f64>>,
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>, pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) log_evidence: f64, pub(crate) log_evidence: f64,
} }
@@ -119,16 +115,10 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
convergence, convergence,
&mut arena, &mut arena,
); );
let likelihoods = g.likelihoods;
let log_evidence = g.log_evidence;
Self { Self {
teams, teams,
result, likelihoods: g.likelihoods,
weights, log_evidence: g.log_evidence,
p_draw,
convergence,
likelihoods,
log_evidence,
} }
} }
@@ -148,16 +138,10 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
convergence, convergence,
&mut arena, &mut arena,
); );
let likelihoods = g.likelihoods;
let log_evidence = g.log_evidence;
Self { Self {
teams, teams,
result: scores, likelihoods: g.likelihoods,
weights, log_evidence: g.log_evidence,
p_draw: 0.0,
convergence,
likelihoods,
log_evidence,
} }
} }
+35
View File
@@ -69,7 +69,20 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
} }
} }
/// Probability that two evenly-matched sides draw.
///
/// Must be in `[0.0, 1.0)`. A zero draw probability asserts that draws
/// cannot occur, so ingesting a tied outcome then fails with
/// `InferenceError::TieWithoutDrawProbability`.
///
/// # Panics
///
/// Panics if `p_draw` is outside `[0.0, 1.0)` or is NaN.
pub fn p_draw(mut self, p_draw: f64) -> Self { pub fn p_draw(mut self, p_draw: f64) -> Self {
assert!(
(0.0..1.0).contains(&p_draw),
"p_draw must be in [0.0, 1.0) (got {p_draw})"
);
self.p_draw = p_draw; self.p_draw = p_draw;
self self
} }
@@ -79,6 +92,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
self self
} }
/// Default observation noise for scored outcomes.
///
/// # Panics
///
/// Panics if `score_sigma` is not strictly positive.
pub fn score_sigma(mut self, score_sigma: f64) -> Self { pub fn score_sigma(mut self, score_sigma: f64) -> Self {
assert!( assert!(
score_sigma > 0.0, score_sigma > 0.0,
@@ -88,7 +106,24 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
self self
} }
/// Convergence tolerance, iteration cap, and EP damping.
///
/// # Panics
///
/// Panics if `alpha` is outside `(0.0, 1.0]`, or if `epsilon` is negative
/// or NaN. An `alpha` of zero would leave every EP update unapplied, so
/// inference would silently return the priors.
pub fn convergence(mut self, opts: ConvergenceOptions) -> Self { pub fn convergence(mut self, opts: ConvergenceOptions) -> Self {
assert!(
opts.alpha > 0.0 && opts.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0] (got {})",
opts.alpha
);
assert!(
opts.epsilon >= 0.0,
"convergence epsilon must be non-negative (got {})",
opts.epsilon
);
self.convergence = opts; self.convergence = opts;
self self
} }
+18 -1
View File
@@ -39,7 +39,7 @@ pub use event::{Event, Member, Team};
pub use event_builder::EventBuilder; pub use event_builder::EventBuilder;
pub use game::{Game, GameOptions, OwnedGame}; pub use game::{Game, GameOptions, OwnedGame};
pub use gaussian::Gaussian; pub use gaussian::Gaussian;
pub use history::History; pub use history::{History, HistoryBuilder};
pub use key_table::KeyTable; pub use key_table::KeyTable;
use matrix::Matrix; use matrix::Matrix;
pub use observer::{NullObserver, Observer}; pub use observer::{NullObserver, Observer};
@@ -65,12 +65,29 @@ pub const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)] #[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
pub struct Index(usize); pub struct Index(usize);
impl Index {
/// The underlying slot number.
///
/// Indices are dense and assigned in interning order, so this is usable as
/// a key into a caller-side side table.
#[must_use]
pub fn get(self) -> usize {
self.0
}
}
impl From<usize> for Index { impl From<usize> for Index {
fn from(ix: usize) -> Self { fn from(ix: usize) -> Self {
Self(ix) Self(ix)
} }
} }
impl From<Index> for usize {
fn from(idx: Index) -> Self {
idx.0
}
}
fn erfc(x: f64) -> f64 { fn erfc(x: f64) -> f64 {
let z = x.abs(); let z = x.abs();
let t = 1.0 / (1.0 + z / 2.0); let t = 1.0 / (1.0 + z / 2.0);
+18
View File
@@ -29,6 +29,24 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
} }
} }
/// The configured prior skill estimate.
#[must_use]
pub fn prior(&self) -> Gaussian {
self.prior
}
/// Performance noise: how much a single showing varies around the skill.
#[must_use]
pub fn beta(&self) -> f64 {
self.beta
}
/// The drift model governing how skill may move between events.
#[must_use]
pub fn drift(&self) -> D {
self.drift
}
pub(crate) fn performance(&self) -> Gaussian { pub(crate) fn performance(&self) -> Gaussian {
self.prior.forget(self.beta.powi(2)) self.prior.forget(self.beta.powi(2))
} }
+31 -5
View File
@@ -32,8 +32,17 @@ pub struct EpsilonOrMax {
impl Default for EpsilonOrMax { impl Default for EpsilonOrMax {
fn default() -> Self { fn default() -> Self {
// Matches today's hard-coded tolerance and iteration cap. // Derived from `ConvergenceOptions` so there is one source of truth for
Self { eps: 1e-6, max: 10 } // the tolerance and iteration cap. These previously disagreed: this
// default capped at 10 iterations while `ConvergenceOptions` allowed 30,
// and which applied depended on whether inference went through
// `run_chain` or a `Schedule`.
let defaults = crate::ConvergenceOptions::default();
Self {
eps: defaults.epsilon,
max: defaults.max_iter,
}
} }
} }
@@ -50,10 +59,16 @@ impl Schedule for EpsilonOrMax {
} }
let mut iterations = 0; let mut iterations = 0;
let mut final_step = (f64::INFINITY, f64::INFINITY); // With no iterating factors the graph is already at its fixed point:
let mut converged = false; // the setup pass above is all there is to do. Reporting `converged:
// false` with an infinite step for that case gave callers a false
// negative.
let mut final_step = (0.0, 0.0);
let mut converged = true;
if n_setup < factors.len() { if n_setup < factors.len() {
final_step = (f64::INFINITY, f64::INFINITY);
converged = false;
for _ in 0..self.max { for _ in 0..self.max {
let mut step = (0.0_f64, 0.0_f64); let mut step = (0.0_f64, 0.0_f64);
@@ -113,7 +128,8 @@ mod tests {
#[test] #[test]
fn report_marks_converged_when_no_iterating_factors() { fn report_marks_converged_when_no_iterating_factors() {
// No iterating factors → 0 iterations, converged stays false (loop never ran). // A graph of only setup factors has nothing to iterate, so it is at its
// fixed point after the setup pass: 0 iterations, and converged.
let mut vars = VarStore::new(); let mut vars = VarStore::new();
let out = vars.alloc(N_INF); let out = vars.alloc(N_INF);
let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor { let mut factors = vec![BuiltinFactor::TeamSum(TeamSumFactor {
@@ -122,5 +138,15 @@ mod tests {
})]; })];
let report = EpsilonOrMax::default().run(&mut factors, &mut vars); let report = EpsilonOrMax::default().run(&mut factors, &mut vars);
assert_eq!(report.iterations, 0); assert_eq!(report.iterations, 0);
assert!(report.converged);
assert_eq!(report.final_step, (0.0, 0.0));
}
#[test]
fn default_matches_convergence_options() {
let schedule = EpsilonOrMax::default();
let options = crate::ConvergenceOptions::default();
assert_eq!(schedule.max, options.max_iter);
assert_eq!(schedule.eps, options.epsilon);
} }
} }
+12 -15
View File
@@ -41,6 +41,18 @@ impl SkillStore {
} }
} }
/// Whether a slot is occupied. Test-only.
#[cfg(test)]
pub fn contains(&self, idx: Index) -> bool {
idx.0 < self.present.len() && self.present[idx.0]
}
/// Number of occupied slots. Test-only.
#[cfg(test)]
pub fn len(&self) -> usize {
self.n_present
}
pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> { pub fn get_mut(&mut self, idx: Index) -> Option<&mut Skill> {
if idx.0 < self.present.len() && self.present[idx.0] { if idx.0 < self.present.len() && self.present[idx.0] {
Some(&mut self.skills[idx.0]) Some(&mut self.skills[idx.0])
@@ -49,21 +61,6 @@ impl SkillStore {
} }
} }
#[allow(dead_code)]
pub fn contains(&self, idx: Index) -> bool {
idx.0 < self.present.len() && self.present[idx.0]
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.n_present
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.n_present == 0
}
pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> { pub fn iter(&self) -> impl Iterator<Item = (Index, &Skill)> {
self.present.iter().enumerate().filter_map(|(i, &p)| { self.present.iter().enumerate().filter_map(|(i, &p)| {
if p { if p {
+27 -8
View File
@@ -14,7 +14,6 @@ use crate::{
rating::Rating, rating::Rating,
storage::{CompetitorStore, SkillStore}, storage::{CompetitorStore, SkillStore},
time::Time, time::Time,
tuple_gt, tuple_max,
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -504,18 +503,29 @@ impl<T: Time> TimeSlice<T> {
} }
} }
#[allow(dead_code)] /// Iterate this slice alone until its posteriors stop moving, returning
/// the number of iterations taken.
///
/// Only used by tests: production convergence is driven across slices by
/// `History::converge`.
///
/// Honours `self.convergence`; it previously hard-coded an epsilon and a
/// 20-iteration cap that matched neither `ConvergenceOptions` nor the
/// schedule default.
#[cfg(test)]
pub(crate) fn iterate_to_convergence<D: Drift<T>>( pub(crate) fn iterate_to_convergence<D: Drift<T>>(
&mut self, &mut self,
agents: &CompetitorStore<T, D>, agents: &CompetitorStore<T, D>,
) -> usize { ) -> usize {
let epsilon = 1e-6; use crate::{tuple_gt, tuple_max};
let iterations = 20;
let epsilon = self.convergence.epsilon;
let max_iter = self.convergence.max_iter;
let mut step = (f64::INFINITY, f64::INFINITY); let mut step = (f64::INFINITY, f64::INFINITY);
let mut i = 0; let mut i = 0;
while tuple_gt(step, epsilon) && i < iterations { while tuple_gt(step, epsilon) && i < max_iter {
let old = self.posteriors(); let old = self.posteriors();
self.iteration(0, agents); self.iteration(0, agents);
@@ -527,6 +537,10 @@ impl<T: Time> TimeSlice<T> {
}); });
i += 1; i += 1;
if !crate::step_is_finite(step) {
break;
}
} }
i i
@@ -918,19 +932,24 @@ mod tests {
let post = time_slice.posteriors(); let post = time_slice.posteriors();
// These are convergence residuals, not exact values: by symmetry the
// true mean is 25.0 and the iteration approaches it from above. The
// previous expectation of 25.000003 was the residual after the
// hard-coded 20-iteration cap; honouring `ConvergenceOptions` runs to
// 30 and lands nearer the truth.
assert_ulps_eq!( assert_ulps_eq!(
post[&a], post[&a],
Gaussian::from_ms(25.000003, 3.880150), Gaussian::from_ms(25.000001, 3.880150),
epsilon = 1e-6 epsilon = 1e-6
); );
assert_ulps_eq!( assert_ulps_eq!(
post[&b], post[&b],
Gaussian::from_ms(25.000003, 3.880150), Gaussian::from_ms(25.000001, 3.880150),
epsilon = 1e-6 epsilon = 1e-6
); );
assert_ulps_eq!( assert_ulps_eq!(
post[&c], post[&c],
Gaussian::from_ms(25.000003, 3.880150), Gaussian::from_ms(25.000001, 3.880150),
epsilon = 1e-6 epsilon = 1e-6
); );
} }