From 68be7ab5b7476509e4d829c2aed0ee781c189f64 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 8 May 2026 15:34:58 +0200 Subject: [PATCH] test(history): end-to-end ConvergenceOptions propagation tests Two integration tests on a 4-team ranked event: - max_iter=1 set on HistoryBuilder produces measurably different posteriors than default, proving the inner loop honors the propagated max_iter - alpha=0.5 with extra iterations reaches the same fixed point as alpha=1.0, proving damping doesn't break correctness on the History path Also updates the alpha doc comment to clarify it applies only to the within-game EP loop, not the outer cross-history sweep. --- src/convergence.rs | 11 ++++-- src/history.rs | 93 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/convergence.rs b/src/convergence.rs index ab49639..37d2136 100644 --- a/src/convergence.rs +++ b/src/convergence.rs @@ -9,9 +9,14 @@ pub struct ConvergenceOptions { pub max_iter: usize, pub epsilon: f64, /// EP damping factor in natural-parameter space: each per-factor - /// update 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]`. + /// 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, } diff --git a/src/history.rs b/src/history.rs index 91c72c9..3b08387 100644 --- a/src/history.rs +++ b/src/history.rs @@ -1714,4 +1714,97 @@ mod tests { fn history_builder_rejects_zero_score_sigma() { let _ = History::builder().score_sigma(0.0).build(); } + + #[test] + fn history_propagates_convergence_to_inner_run_chain() { + use crate::ConvergenceOptions; + + let events_for = + |h: &mut History| { + h.event(0) + .team(["a"]) + .team(["b"]) + .team(["c"]) + .team(["d"]) + .ranking([0u32, 1, 2, 3]) + .commit() + .unwrap(); + }; + + let mut h_capped: History = History::builder() + .convergence(ConvergenceOptions { + max_iter: 1, + ..ConvergenceOptions::default() + }) + .build(); + events_for(&mut h_capped); + h_capped.converge().unwrap(); + + let mut h_full: History = History::builder().build(); + events_for(&mut h_full); + h_full.converge().unwrap(); + + let curves_capped = h_capped.learning_curves(); + let curves_full = h_full.learning_curves(); + + let mut max_diff: f64 = 0.0; + for (key, capped_pts) in curves_capped.iter() { + let full_pts = curves_full.get(key).expect("agent missing in full"); + for (capped, full) in capped_pts.iter().zip(full_pts.iter()) { + max_diff = max_diff.max((capped.1.mu() - full.1.mu()).abs()); + max_diff = max_diff.max((capped.1.sigma() - full.1.sigma()).abs()); + } + } + assert!( + max_diff > 1e-6, + "max_iter=1 inner loop should differ from default; max_diff={max_diff}" + ); + } + + #[test] + fn history_with_damping_reaches_same_fixed_point_as_undamped() { + use crate::ConvergenceOptions; + + let events_for = + |h: &mut History| { + h.event(0) + .team(["a"]) + .team(["b"]) + .team(["c"]) + .team(["d"]) + .ranking([0u32, 1, 2, 3]) + .commit() + .unwrap(); + }; + + let mut h_undamped: History = History::builder().build(); + events_for(&mut h_undamped); + h_undamped.converge().unwrap(); + + let mut h_damped: History = History::builder() + .convergence(ConvergenceOptions { + alpha: 0.5, + max_iter: 200, + ..ConvergenceOptions::default() + }) + .build(); + events_for(&mut h_damped); + h_damped.converge().unwrap(); + + let curves_u = h_undamped.learning_curves(); + let curves_d = h_damped.learning_curves(); + + let mut max_diff: f64 = 0.0; + for (key, u_pts) in curves_u.iter() { + let d_pts = curves_d.get(key).expect("agent missing in damped"); + for (u, d) in u_pts.iter().zip(d_pts.iter()) { + max_diff = max_diff.max((u.1.mu() - d.1.mu()).abs()); + max_diff = max_diff.max((u.1.sigma() - d.1.sigma()).abs()); + } + } + assert!( + max_diff < 1e-3, + "α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}" + ); + } }