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
+27 -8
View File
@@ -14,7 +14,6 @@ use crate::{
rating::Rating,
storage::{CompetitorStore, SkillStore},
time::Time,
tuple_gt, tuple_max,
};
#[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>>(
&mut self,
agents: &CompetitorStore<T, D>,
) -> usize {
let epsilon = 1e-6;
let iterations = 20;
use crate::{tuple_gt, tuple_max};
let epsilon = self.convergence.epsilon;
let max_iter = self.convergence.max_iter;
let mut step = (f64::INFINITY, f64::INFINITY);
let mut i = 0;
while tuple_gt(step, epsilon) && i < iterations {
while tuple_gt(step, epsilon) && i < max_iter {
let old = self.posteriors();
self.iteration(0, agents);
@@ -527,6 +537,10 @@ impl<T: Time> TimeSlice<T> {
});
i += 1;
if !crate::step_is_finite(step) {
break;
}
}
i
@@ -918,19 +932,24 @@ mod tests {
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!(
post[&a],
Gaussian::from_ms(25.000003, 3.880150),
Gaussian::from_ms(25.000001, 3.880150),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&b],
Gaussian::from_ms(25.000003, 3.880150),
Gaussian::from_ms(25.000001, 3.880150),
epsilon = 1e-6
);
assert_ulps_eq!(
post[&c],
Gaussian::from_ms(25.000003, 3.880150),
Gaussian::from_ms(25.000001, 3.880150),
epsilon = 1e-6
);
}