feat!: make a short fit an error and raise the default iteration cap

`ITERATIONS` was 30, and overrunning it returned `Ok` with
`converged: false`. Both halves were wrong.

The cap is a runaway guard, not a budget: the sweep exits as soon as the
step falls below `epsilon`, so a high cap costs nothing on a history that
converges. Measured on one needing four sweeps, `max_iter` 30 and
100_000 both finish in 4 iterations and ~130us. So 30 could never make
anything faster — it could only stop a healthy history early, and it did:
160 events over 100 competitors already needs 42.

Not scaled to the history, because iteration count tracks how loopy the
graph is rather than how big it is. At a fixed 320 events over 40 slices,
varying only the competitors sharing them: 3 competitors needs 2_789
sweeps, 10 needs 1_068, 100 needs 90, 400 needs 2. Three orders of
magnitude on identical event and slice counts, so any formula in those
two numbers would be badly wrong on some real shape. A single value set
high enough that reaching it means oscillation is the honest version.

With the cap raised, stopping at it means something is genuinely wrong,
so `converge` now returns `InferenceError::NotConverged` rather than a
flag on a success. A short fit is wrong by a little — every rating
finite, the ordering sensible, nothing saying the numbers were still
moving — and a flag has to be checked while `let _ = h.converge()` is the
natural way not to. That is not hypothetical: it is how a real defect hid
in this crate's own test suite.

`converge_partial` returns the short fit for callers who want one. Only a
single existing test needed it, which is the evidence that a capped fit
is a deliberate choice rather than the common case.

Also corrects the `ITERATIONS` docs, which claimed convergence cost is
"roughly linear in the cap". It is linear in the iterations actually run.

BREAKING CHANGE: `History::converge` returns `Err(NotConverged)` where it
previously returned `Ok` with `converged: false`. Callers that want the
old behaviour should use `History::converge_partial`. The default
`max_iter` changes from 30 to 10_000, so a history that was silently
truncated will now converge properly and its numbers will move.

Closes #50

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-08 16:04:22 +02:00
co-authored by Claude Opus 5
parent 7c6965c6a9
commit eff63dfa2a
5 changed files with 282 additions and 23 deletions
+55 -6
View File
@@ -1390,17 +1390,62 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
))
}
/// Run the full forward+backward convergence loop and return a summary.
/// Run the full forward+backward convergence loop to a fixed point.
///
/// Failing to reach `epsilon` within `max_iter` is not an error: the
/// returned report carries `converged: false` and the final step.
/// # Stopping short is an error
///
/// Hitting `max_iter` without reaching `epsilon` returns `NotConverged`.
///
/// It used to return `Ok` with `converged: false`, which was the worst
/// available shape. A fit that stops short is *wrong by a little*: every
/// rating is finite, the ordering looks sensible, and nothing about the
/// output says the numbers were still moving. Detection was opt-in, and
/// `let _ = h.converge()` silently opted out — which is how a real defect
/// hid in this crate's own test suite.
///
/// The default `max_iter` is [`ITERATIONS`](crate::ITERATIONS), which is
/// set high enough that reaching it means something is genuinely wrong
/// rather than that the history is merely large. Raising the cap costs
/// nothing when it is not needed, because the loop exits at `epsilon`.
///
/// Use [`History::converge_partial`] when a capped, unconverged fit is
/// what you actually want.
///
/// # Errors
///
/// `NotConverged` if the sweep hits `max_iter` with the step still above
/// `epsilon`.
///
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
/// broken down at that point and further iterations cannot recover, so the
/// loop stops rather than reporting a NaN step as convergence.
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
let report = self.converge_partial()?;
if report.converged {
Ok(report)
} else {
Err(InferenceError::NotConverged {
iterations: report.iterations,
final_step: report.final_step,
epsilon: self.convergence.epsilon,
})
}
}
/// As [`History::converge`], but a fit that stops at `max_iter` is
/// returned rather than reported as an error.
///
/// The report's `converged` flag says which happened. Use this when a
/// deliberately capped sweep is the point — a cheap approximate fit, or a
/// test that pins what a fixed number of iterations produces. Prefer
/// `converge` everywhere else: an unconverged fit that nobody checks is
/// indistinguishable from a converged one.
///
/// # Errors
///
/// `NonFiniteResult` if a sweep produces a NaN or infinite step.
pub fn converge_partial(&mut self) -> Result<ConvergenceReport, InferenceError> {
use std::time::Instant;
use smallvec::SmallVec;
@@ -2799,13 +2844,15 @@ mod tests {
epsilon = 1e-6
);
// run exactly 11 iterations (old test used convergence(11, ...))
// Run exactly 11 iterations. `converge_partial` rather than
// `converge`: stopping at the cap is the point here, and `converge`
// now reports that as `NotConverged`.
h.convergence = ConvergenceOptions {
max_iter: 11,
epsilon: EPSILON,
alpha: 1.0,
};
let _ = h.converge().unwrap();
let _ = h.converge_partial().unwrap();
let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt();
@@ -3172,7 +3219,9 @@ mod tests {
})
.build();
events_for(&mut h_capped);
let _ = h_capped.converge().unwrap();
// A one-iteration cap is deliberate here, so the short fit is the
// result rather than an error.
let _ = h_capped.converge_partial().unwrap();
let mut h_full: History<i64, _, _, &'static str> = History::builder().build();
events_for(&mut h_full);