Closes the gap between HistoryBuilder::convergence(opts) and the within-game inference loop. TimeSlice gains a convergence field; History passes self.convergence at construction; the three Game::*_with_arena callsites in time_slice.rs read it. Also renames TimeSlice::convergence the method (now iterate_to_convergence) to disambiguate from the new field. Pure plumbing — no new public API, no behavioral change for users on default options. Makes Damped EP reachable through the History path.
8.5 KiB
History → TimeSlice ConvergenceOptions Plumbing
Summary
Make History's already-public ConvergenceOptions (set via
HistoryBuilder::convergence(...)) actually reach the within-game
inference loop. Today it's read by the outer History::converge sweep
but dropped on the floor when constructing TimeSlices, so users who
opt in to alpha < 1.0 (Damped EP) on a History get nothing — the
inner run_chain calls inside TimeSlice hardcode
ConvergenceOptions::default().
This spec closes the gap with one focused change: thread
ConvergenceOptions from History through TimeSlice to the three
Game::*_with_arena callsites in time_slice.rs. No new types, no new
public methods on History or HistoryBuilder — the user-facing API
already exists.
Background
After T5 (commit 0705986) of the Damped EP plan,
Game::*_with_arena accepts convergence: ConvergenceOptions and
run_chain reads self.convergence.{epsilon, max_iter, alpha}.
HistoryBuilder already has a convergence(opts) method (history.rs:91)
that stores onto a field on History. History::converge reads
self.convergence.{max_iter, epsilon} for its outer cross-history loop
(history.rs:437-447).
The break is here, in History::add_events_with_prior at history.rs:597:
let mut time_slice = TimeSlice::new(t, self.p_draw);
self.convergence is not passed. TimeSlice has no convergence field.
The three callsites in time_slice.rs that build Game::*_with_arena
fall back to ConvergenceOptions::default():
Event::iteration_direct(time_slice.rs:138-156)TimeSlice::convergence(time_slice.rs:332-345)TimeSlice::log_evidence(time_slice.rs:521-538)
Scope
What ships
TimeSlice<T>gains apub(crate) convergence: ConvergenceOptionsfield set at construction.TimeSlice::newsignature becomespub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self.History::add_events_with_prior(history.rs:597) passesself.convergencewhen constructing newTimeSlices.Event::iteration_directgains aconvergence: ConvergenceOptionsparameter and forwards it to theGame::*_with_arenacallsite. The two callers (TimeSlice::iterationattime_slice.rs:419and:441) passself.convergence.TimeSlice::convergence(the method, not the field) replaces its hardcodedcrate::ConvergenceOptions::default()withself.convergence.TimeSlice::log_evidencedoes the same.- Five test callsites of
TimeSlice::new(time, p_draw)updated mechanically toTimeSlice::new(time, p_draw, ConvergenceOptions::default()).
What does not ship
- No split of
ConvergenceOptionsinto outer/inner fields. The conflation (onemax_itercovers both the cross-history sweep and the per-game EP iteration cap) is the user-confirmed design. - No
Dampedimpl insrc/schedule.rs. TheScheduletrait is still not integrated intorun_chain. - No nat-param convergence switch.
- No oscillation auto-detect.
- No new
HistoryorHistoryBuildermethods.convergence(opts)already exists and works. - No changes to
History::converge— the outer-loop semantics are unchanged (it already readsself.convergence).
Design
TimeSlice<T> field
// src/time_slice.rs
pub struct TimeSlice<T: Time = i64> {
// ... existing fields ...
p_draw: f64,
pub(crate) convergence: ConvergenceOptions,
// ... existing fields ...
}
TimeSlice::new
impl<T: Time> TimeSlice<T> {
pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self {
Self {
// ... existing initialisation ...
p_draw,
convergence,
// ...
}
}
}
History::add_events_with_prior — single-line fix
At src/history.rs:597:
// before
let mut time_slice = TimeSlice::new(t, self.p_draw);
// after
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
Event::iteration_direct parameter
// src/time_slice.rs
impl Event {
pub(crate) fn iteration_direct(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<i64, ConstantDrift>,
p_draw: f64,
convergence: ConvergenceOptions,
arena: &mut ScratchArena,
) -> /* existing return */ {
// ... existing body, with the Game::*_with_arena calls
// using `convergence` instead of ConvergenceOptions::default() ...
}
}
The two callers — TimeSlice::iteration at time_slice.rs:419 and
:441 — already have &mut self access, so they pass
self.convergence.
TimeSlice::convergence method (not the field)
The method pub(crate) fn convergence<D>(&mut self, agents: ...) -> usize
at time_slice.rs:447 shares its name with the new field. Rust allows
this (methods and fields live in different namespaces), but it's a
readability hazard. Rename the method to iterate_to_convergence to
disambiguate.
This is one rename, six callsites in history.rs and the test module.
Field semantics
History keeps the single shared ConvergenceOptions struct. The same
max_iter covers both the outer sweep and each inner per-game loop.
The same epsilon covers both stopping criteria. The alpha field is
read only inside run_chain (the inner loop); the outer loop
intentionally ignores alpha because cross-history damping is a
different mathematical concept and not in scope.
Testing strategy
Regression net
The existing 98 lib + 27 integration tests are the bit-equal regression
net. Default ConvergenceOptions is unchanged
(max_iter=30, epsilon=1e-6, alpha=1.0), and TimeSlice was already
using exactly that since T5. The only behavioural difference is for
users who actually pass non-default options through
HistoryBuilder::convergence(...) — and there are no current tests that
do that and compare posteriors, so all goldens stay bit-equal.
New tests
-
history_propagates_convergence_to_inner_run_chain(insrc/history.rstest module):- Build a History with
convergence(ConvergenceOptions { max_iter: 1, ..Default::default() }). - Add a small batch of events that needs more than one inner EP iteration to converge (e.g. a 4-team game per slice).
converge(), capture posteriors.- Build a fresh History with default options on the same events.
converge(), capture posteriors.- Assert the two sets of posteriors differ measurably (max diff > 1e-6).
- Proves the inner loop honours the propagated
max_iter. Today (without this change) the assertion would fail because both Histories use default inside.
- Build a History with
-
history_with_damping_reaches_same_fixed_point_as_undamped(same test module):- Build a History with
convergence(ConvergenceOptions { alpha: 0.5, max_iter: 200, ..Default::default() }). - Same events as above.
converge(), capture posteriors.- Build a default-options History on the same events.
converge(), capture posteriors.- Assert per-player posteriors agree within 1e-3.
- Proves damping doesn't break convergence on the History path.
- Build a History with
If the second test's max diff is too large, raise max_iter further
(damping needs more iterations to reach the same fixed point).
Verification gates
cargo +nightly fmt
cargo clippy --all-targets -- -D warnings
cargo test --lib
cargo test
All must succeed. Test count grows by exactly 2 (the two new tests).
Risks
TimeSlice::newispub. Adding the third parameter is a breaking change to a public constructor. In a 0.1.x crate this is acceptable, but flag it in the commit message.TimeSlice::convergencemethod rename. Renamingconvergence→iterate_to_convergencetoucheshistory.rsand the TimeSlice test module. The rename is mechanical and improves readability where the field and method would otherwise share a name.- Cross-history alpha semantics. A user who sets
alpha = 0.5on aHistorygets damping inside every per-game loop, but the outerHistory::convergesweep is undamped. This is the correct semantic (alpha is a within-EP-graph concept) but it's worth documenting in theConvergenceOptions::alphadoc comment so users don't expect cross-slice damping. Add one sentence to the existing doc comment.
Out-of-scope follow-ups
- Wire
Scheduletrait intorun_chain— Damped becomes aScheduleimpl alongsideEpsilonOrMax. - Per-loop
ConvergenceOptionssplit (outer / inner). Residualschedule.- Per-event
EventKind::Scored.score_sigmaoverride (still history-wide today).