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
+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 {
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
}
@@ -79,6 +92,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
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 {
assert!(
score_sigma > 0.0,
@@ -88,7 +106,24 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
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 {
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
}