Both are public defaults for "how many EP iterations before giving up", and they disagree by 3×. Which one applies depends on whether inference goes through run_chain (reads ConvergenceOptions, src/game.rs:279) or through a Schedule (EpsilonOrMax). A user who configures one and gets the other's behaviour has no way to tell from the API.
Pick one source of truth. Since run_chain is the path everything actually uses, EpsilonOrMax::default() should derive from ConvergenceOptions::default() — or the two should be unified outright (see #6, which needs Schedule generalised anyway).
2. ScheduleReport.converged is false when there was nothing to converge
EpsilonOrMax::run initialises converged = false and only sets it inside the iteration loop (src/schedule.rs:52-81). If the factor list contains no iterating factors — a graph of only TeamSum setup factors — the loop never runs and the report says converged: false with final_step: (INFINITY, INFINITY), despite the graph being trivially at its fixed point.
The existing test documents the behaviour rather than questioning it (src/schedule.rs:114-125):
// No iterating factors → 0 iterations, converged stays false (loop never ran).
A caller checking report.converged gets a false negative. A graph with nothing to iterate has converged.
3. alpha is documented as validated but isn't
ConvergenceOptions.alpha says "Must be in (0.0, 1.0]" (src/convergence.rs:14). Nothing enforces it outside a debug_assert! in the game constructors (src/game.rs:213, 252), and HistoryBuilder::convergence accepts the struct wholesale with no check (src/history.rs:91). Consequences in release:
alpha = 0.0 → damp_natural returns the old message unchanged every time (src/gaussian.rs:112), so no factor ever updates. The loop runs to max_iter and reports converged: false — but silently, and the posteriors are just the priors.
alpha > 1.0 → over-relaxation, which can diverge.
See #18 for the general validation problem; this specific one is worth calling out because the failure mode is "silently returns the prior".
Also
ScheduleReport.final_step is (f64, f64) throughout — a bare tuple whose meaning (|Δmu|, |Δsigma|) lives only in a doc comment on Factor::propagate (src/factor/mod.rs:62). Given #15 proposes switching these to (|Δpi|, |Δtau|), a named type would prevent the units silently changing meaning under callers.
Acceptance
One documented default iteration cap, used by every path.
A graph with no iterating factors reports converged: true.
Out-of-range alpha is rejected at the API boundary in release builds.
Three unrelated-looking defects that all make convergence reporting untrustworthy.
## 1. Two different default iteration caps
- `ConvergenceOptions::default()` → `max_iter: crate::ITERATIONS` = **30** (`src/convergence.rs:26`, `src/lib.rs:55`)
- `EpsilonOrMax::default()` → `max: 10` (`src/schedule.rs:36`), commented "Matches today's hard-coded tolerance and iteration cap"
Both are public defaults for "how many EP iterations before giving up", and they disagree by 3×. Which one applies depends on whether inference goes through `run_chain` (reads `ConvergenceOptions`, `src/game.rs:279`) or through a `Schedule` (`EpsilonOrMax`). A user who configures one and gets the other's behaviour has no way to tell from the API.
Pick one source of truth. Since `run_chain` is the path everything actually uses, `EpsilonOrMax::default()` should derive from `ConvergenceOptions::default()` — or the two should be unified outright (see #6, which needs `Schedule` generalised anyway).
## 2. `ScheduleReport.converged` is `false` when there was nothing to converge
`EpsilonOrMax::run` initialises `converged = false` and only sets it inside the iteration loop (`src/schedule.rs:52-81`). If the factor list contains no iterating factors — a graph of only `TeamSum` setup factors — the loop never runs and the report says `converged: false` with `final_step: (INFINITY, INFINITY)`, despite the graph being trivially at its fixed point.
The existing test documents the behaviour rather than questioning it (`src/schedule.rs:114-125`):
```rust
// No iterating factors → 0 iterations, converged stays false (loop never ran).
```
A caller checking `report.converged` gets a false negative. A graph with nothing to iterate has converged.
## 3. `alpha` is documented as validated but isn't
`ConvergenceOptions.alpha` says "Must be in `(0.0, 1.0]`" (`src/convergence.rs:14`). Nothing enforces it outside a `debug_assert!` in the game constructors (`src/game.rs:213`, `252`), and `HistoryBuilder::convergence` accepts the struct wholesale with no check (`src/history.rs:91`). Consequences in release:
- `alpha = 0.0` → `damp_natural` returns the old message unchanged every time (`src/gaussian.rs:112`), so no factor ever updates. The loop runs to `max_iter` and reports `converged: false` — but silently, and the posteriors are just the priors.
- `alpha > 1.0` → over-relaxation, which can diverge.
See #18 for the general validation problem; this specific one is worth calling out because the failure mode is "silently returns the prior".
## Also
`ScheduleReport.final_step` is `(f64, f64)` throughout — a bare tuple whose meaning (`|Δmu|`, `|Δsigma|`) lives only in a doc comment on `Factor::propagate` (`src/factor/mod.rs:62`). Given #15 proposes switching these to `(|Δpi|, |Δtau|)`, a named type would prevent the units silently changing meaning under callers.
## Acceptance
- One documented default iteration cap, used by every path.
- A graph with no iterating factors reports `converged: true`.
- Out-of-range `alpha` is rejected at the API boundary in release builds.
EpsilonOrMax::default() derives from ConvergenceOptions::default(), so there is one source of truth. Pinned by default_matches_convergence_options.
A graph with no iterating factors now reports converged: true with a zero step. The test that documented the old false negative asserts the new behaviour.
HistoryBuilder::convergence rejects an alpha outside (0.0, 1.0] and a negative epsilon, matching the eager-panic convention score_sigma already used; p_draw validates too. An alpha of zero was the nastiest of these — every EP update goes unapplied, so inference silently returns the priors.
TimeSlice::iterate_to_convergence was a fourth source of disagreement — hard-coded epsilon and a 20-iteration cap matching neither default. It reads self.convergence now and is #[cfg(test)], which is all that ever called it. Two goldens moved as a result: they are convergence residuals, and running to 30 iterations instead of 20 lands nearer the analytic truth of 25.0.
Still open (kept in this issue): final_step is still a bare (f64, f64) tuple, and the deltas are still moment-space rather than (|Δpi|, |Δtau|) per spec §5 — see #15, item 3. A named type is worth having before those units change meaning under callers.
Fixed in 6030dc7 — all three.
1. `EpsilonOrMax::default()` derives from `ConvergenceOptions::default()`, so there is one source of truth. Pinned by `default_matches_convergence_options`.
2. A graph with no iterating factors now reports `converged: true` with a zero step. The test that documented the old false negative asserts the new behaviour.
3. `HistoryBuilder::convergence` rejects an `alpha` outside `(0.0, 1.0]` and a negative epsilon, matching the eager-panic convention `score_sigma` already used; `p_draw` validates too. An `alpha` of zero was the nastiest of these — every EP update goes unapplied, so inference silently returns the priors.
`TimeSlice::iterate_to_convergence` was a fourth source of disagreement — hard-coded epsilon and a 20-iteration cap matching neither default. It reads `self.convergence` now and is `#[cfg(test)]`, which is all that ever called it. Two goldens moved as a result: they are convergence residuals, and running to 30 iterations instead of 20 lands nearer the analytic truth of 25.0.
**Still open** (kept in this issue): `final_step` is still a bare `(f64, f64)` tuple, and the deltas are still moment-space rather than `(|Δpi|, |Δtau|)` per spec §5 — see #15, item 3. A named type is worth having *before* those units change meaning under callers.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Three unrelated-looking defects that all make convergence reporting untrustworthy.
1. Two different default iteration caps
ConvergenceOptions::default()→max_iter: crate::ITERATIONS= 30 (src/convergence.rs:26,src/lib.rs:55)EpsilonOrMax::default()→max: 10(src/schedule.rs:36), commented "Matches today's hard-coded tolerance and iteration cap"Both are public defaults for "how many EP iterations before giving up", and they disagree by 3×. Which one applies depends on whether inference goes through
run_chain(readsConvergenceOptions,src/game.rs:279) or through aSchedule(EpsilonOrMax). A user who configures one and gets the other's behaviour has no way to tell from the API.Pick one source of truth. Since
run_chainis the path everything actually uses,EpsilonOrMax::default()should derive fromConvergenceOptions::default()— or the two should be unified outright (see #6, which needsSchedulegeneralised anyway).2.
ScheduleReport.convergedisfalsewhen there was nothing to convergeEpsilonOrMax::runinitialisesconverged = falseand only sets it inside the iteration loop (src/schedule.rs:52-81). If the factor list contains no iterating factors — a graph of onlyTeamSumsetup factors — the loop never runs and the report saysconverged: falsewithfinal_step: (INFINITY, INFINITY), despite the graph being trivially at its fixed point.The existing test documents the behaviour rather than questioning it (
src/schedule.rs:114-125):A caller checking
report.convergedgets a false negative. A graph with nothing to iterate has converged.3.
alphais documented as validated but isn'tConvergenceOptions.alphasays "Must be in(0.0, 1.0]" (src/convergence.rs:14). Nothing enforces it outside adebug_assert!in the game constructors (src/game.rs:213,252), andHistoryBuilder::convergenceaccepts the struct wholesale with no check (src/history.rs:91). Consequences in release:alpha = 0.0→damp_naturalreturns the old message unchanged every time (src/gaussian.rs:112), so no factor ever updates. The loop runs tomax_iterand reportsconverged: false— but silently, and the posteriors are just the priors.alpha > 1.0→ over-relaxation, which can diverge.See #18 for the general validation problem; this specific one is worth calling out because the failure mode is "silently returns the prior".
Also
ScheduleReport.final_stepis(f64, f64)throughout — a bare tuple whose meaning (|Δmu|,|Δsigma|) lives only in a doc comment onFactor::propagate(src/factor/mod.rs:62). Given #15 proposes switching these to(|Δpi|, |Δtau|), a named type would prevent the units silently changing meaning under callers.Acceptance
converged: true.alphais rejected at the API boundary in release builds.Fixed in
6030dc7— all three.EpsilonOrMax::default()derives fromConvergenceOptions::default(), so there is one source of truth. Pinned bydefault_matches_convergence_options.converged: truewith a zero step. The test that documented the old false negative asserts the new behaviour.HistoryBuilder::convergencerejects analphaoutside(0.0, 1.0]and a negative epsilon, matching the eager-panic conventionscore_sigmaalready used;p_drawvalidates too. Analphaof zero was the nastiest of these — every EP update goes unapplied, so inference silently returns the priors.TimeSlice::iterate_to_convergencewas a fourth source of disagreement — hard-coded epsilon and a 20-iteration cap matching neither default. It readsself.convergencenow and is#[cfg(test)], which is all that ever called it. Two goldens moved as a result: they are convergence residuals, and running to 30 iterations instead of 20 lands nearer the analytic truth of 25.0.Still open (kept in this issue):
final_stepis still a bare(f64, f64)tuple, and the deltas are still moment-space rather than(|Δpi|, |Δtau|)per spec §5 — see #15, item 3. A named type is worth having before those units change meaning under callers.