//! Convergence configuration and reporting. use std::time::Duration; use smallvec::SmallVec; #[derive(Clone, Copy, Debug, PartialEq)] pub struct ConvergenceOptions { pub max_iter: usize, pub epsilon: f64, /// EP damping factor in natural-parameter space: each per-factor /// update inside a single game writes `α·new + (1−α)·old`. `1.0` is /// undamped (default); `< 1.0` stabilises oscillating fixed-point /// loops at the cost of more iterations. Must be in `(0.0, 1.0]`. /// /// Applies only to the within-game EP loop (`run_chain`). The outer /// `History::converge` cross-history sweep is undamped regardless of /// this value — cross-slice damping is a different concept and not /// in scope. pub alpha: f64, } impl ConvergenceOptions { /// Reject values that would make inference silently meaningless. /// /// `HistoryBuilder::convergence` asserts these eagerly, but the fields are /// public and `GameOptions` carries a `ConvergenceOptions` — so a caller /// can hand `Game::ranked` a set the builder never saw. In release the /// engine's `debug_assert!`s are gone, and an `alpha` of zero leaves every /// EP update unapplied: inference returns the priors, with every likelihood /// uninformative and nothing to indicate anything went wrong. /// /// # Errors /// /// `InvalidParameter` if `alpha` is outside `(0.0, 1.0]` or `epsilon` is /// negative. NaN fails both comparisons and is rejected. pub(crate) fn validate(&self) -> Result<(), crate::InferenceError> { if !(self.alpha > 0.0 && self.alpha <= 1.0) { return Err(crate::InferenceError::InvalidParameter { name: "alpha", value: self.alpha, }); } if self.epsilon.is_nan() || self.epsilon < 0.0 { return Err(crate::InferenceError::InvalidParameter { name: "epsilon", value: self.epsilon, }); } Ok(()) } } impl Default for ConvergenceOptions { fn default() -> Self { Self { max_iter: crate::ITERATIONS, epsilon: crate::EPSILON, alpha: 1.0, } } } /// Post-hoc summary of a `History::converge` call. /// /// From [`History::converge`](crate::History::converge) this always describes a /// converged fit — stopping at `max_iter` is /// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there. /// From [`History::converge_partial`](crate::History::converge_partial) it may /// not be, and `converged` is what says so. #[derive(Clone, Debug, PartialEq)] pub struct ConvergenceReport { pub iterations: usize, pub final_step: (f64, f64), pub log_evidence: f64, pub converged: bool, pub per_iteration_time: SmallVec<[Duration; 32]>, } #[cfg(test)] mod tests { use super::*; #[test] fn default_alpha_is_one_for_undamped_behavior() { let opts = ConvergenceOptions::default(); assert_eq!(opts.alpha, 1.0); } }