5 Commits

Author SHA1 Message Date
logaritmisk 68be7ab5b7 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.
2026-05-08 15:34:58 +02:00
logaritmisk 824b7f50b0 feat(time_slice): inference callsites read self.convergence
The three Game::*_with_arena callsites in time_slice.rs (in
TimeSlice::iteration's sequential branch, TimeSlice::log_evidence's
run_event closure, and Event::iteration_direct via parameter) now use
the propagated ConvergenceOptions instead of hardcoded ::default().
sweep_color_groups (both rayon and non-rayon paths) forwards
self.convergence into Event::iteration_direct.

Damped EP (alpha < 1.0) and custom max_iter / epsilon set on
HistoryBuilder::convergence(opts) now actually reach the within-game
inference loop. Bit-equal for users on default options.

Removes the temporary #[allow(dead_code)] on TimeSlice::convergence
that was added in the prior commit.
2026-05-08 15:32:25 +02:00
logaritmisk 872f91797d refactor(time_slice): add convergence field, rename iterate_to_convergence
TimeSlice<T> gains a pub(crate) convergence: ConvergenceOptions field
set at construction. TimeSlice::new now takes it as a third parameter
(breaking change to the pub constructor, acceptable in 0.1.x).
History::add_events_with_prior passes self.convergence so the propagated
value reaches every TimeSlice. The pre-existing convergence-the-method
is renamed to iterate_to_convergence to disambiguate from the new
convergence-the-field.

The field is wired but not yet read by inference -- the three
Game::*_with_arena callsites in time_slice.rs still hardcode
ConvergenceOptions::default(). Task 2 changes that. Bit-equal because
the propagated value equals the hardcoded value end-to-end.

Also updated benches/batch.rs which has a fourth TimeSlice::new
callsite (not enumerated in the plan -- only src/ files were).
2026-05-08 15:29:39 +02:00
logaritmisk 6e453b6845 docs: implementation plan for History → TimeSlice plumbing
Three tasks: TimeSlice gains convergence field + method rename +
History passes self.convergence (atomic), three inference callsites
read self.convergence, and end-to-end tests + alpha doc-comment update.
2026-05-08 15:26:38 +02:00
logaritmisk 965ea7ed3c docs: spec for History → TimeSlice ConvergenceOptions plumbing
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.
2026-05-08 15:23:11 +02:00
6 changed files with 970 additions and 33 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
use criterion::{Criterion, criterion_group, criterion_main}; use criterion::{Criterion, criterion_group, criterion_main};
use trueskill_tt::{ use trueskill_tt::{
BETA, Competitor, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA, TimeSlice, BETA, Competitor, ConvergenceOptions, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA,
drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore, TimeSlice, drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
}; };
fn criterion_benchmark(criterion: &mut Criterion) { fn criterion_benchmark(criterion: &mut Criterion) {
@@ -35,7 +35,7 @@ fn criterion_benchmark(criterion: &mut Criterion) {
let kinds = vec![EventKind::Ranked; composition.len()]; let kinds = vec![EventKind::Ranked; composition.len()];
let mut time_slice = TimeSlice::new(1, P_DRAW); let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
time_slice.add_events(composition, results, weights, kinds, &agents); time_slice.add_events(composition, results, weights, kinds, &agents);
criterion.bench_function("Batch::iteration", |b| { criterion.bench_function("Batch::iteration", |b| {
@@ -0,0 +1,593 @@
# History → TimeSlice ConvergenceOptions Plumbing Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Thread `ConvergenceOptions` from `History` through `TimeSlice` to the three `Game::*_with_arena` callsites in `time_slice.rs`, so users who set `HistoryBuilder::convergence(opts)` actually get those options applied to within-game inference (including Damped's `alpha`).
**Architecture:** `TimeSlice<T>` gains a `convergence: ConvergenceOptions` field set at construction. `History::add_events_with_prior` passes `self.convergence`. The three `Game::*_with_arena` callsites in `time_slice.rs` swap their hardcoded `ConvergenceOptions::default()` for the propagated value. The pre-existing `TimeSlice::convergence` method is renamed to `iterate_to_convergence` to disambiguate from the new field. No new public API on `History` or `HistoryBuilder``convergence(opts)` already exists and works.
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
---
## Spec reference
`docs/superpowers/specs/2026-05-08-history-convergence-plumbing-design.md`
## Pre-flight context for the implementer
- `HistoryBuilder::convergence(opts)` already exists at `src/history.rs:91`. `History` already stores `convergence: ConvergenceOptions` at `src/history.rs:166`. `History::converge()` already reads `self.convergence.{epsilon, max_iter}` at `src/history.rs:437-447` for the OUTER cross-history loop.
- `TimeSlice<T>` is at `src/time_slice.rs:172-180`. Currently has fields `events`, `skills`, `time`, `p_draw`, `arena`, `color_groups`. No convergence field yet.
- `TimeSlice::new(time, p_draw)` at `src/time_slice.rs:183-192` is `pub`. Five test callsites use it with `(0i64, 0.0)`. One production callsite in `History::add_events_with_prior` at `src/history.rs:597` uses `(t, self.p_draw)`.
- Three callsites in `time_slice.rs` call `Game::*_with_arena` with hardcoded `crate::ConvergenceOptions::default()`:
- `Event::iteration_direct` at `src/time_slice.rs:131-169` — does NOT have `&self` access to a TimeSlice. Currently takes `(skills, agents, p_draw, arena)`. Needs to gain a `convergence` parameter.
- `TimeSlice::iteration` at `src/time_slice.rs:322-363` — has `&mut self`, so reads `self.convergence` directly.
- `TimeSlice::log_evidence` at `src/time_slice.rs:505-540` — has `&self`, so reads `self.convergence` directly.
- The rayon path in `sweep_color_groups` at `src/time_slice.rs:376-423` uses a `move` closure capturing `p_draw` by value. The same pattern applies to `convergence` (it's `Copy`, so captures cleanly).
- `TimeSlice::convergence` (the **method** at `src/time_slice.rs:447`) shares its name with the new field. Rust technically allows this (different namespaces), but it's a readability hazard — must be renamed. The method is called from 4 test sites in `time_slice.rs` (lines 693, 755, 817, 851). It is NOT called from `history.rs`.
- `ConvergenceOptions` is `Copy + Clone + Debug`. Pass by value everywhere.
## File map
| File | Why touched |
|---|---|
| `src/time_slice.rs` | TimeSlice gains `convergence` field, `new` signature change, rename `convergence` method, three callsites read `self.convergence`, `Event::iteration_direct` gains parameter, rayon closure captures it |
| `src/history.rs` | `add_events_with_prior` passes `self.convergence` to `TimeSlice::new`; two integration tests added; alpha doc-comment update happens in `convergence.rs` not here |
| `src/convergence.rs` | One-sentence addition to `alpha` doc comment clarifying within-game-only scope |
---
### Task 1: TimeSlice gains `convergence` field; signature/rename land atomically
This task does five things atomically — they cannot land separately because intermediate states won't compile:
1. Add `pub(crate) convergence: ConvergenceOptions` field to `TimeSlice<T>`.
2. Change `TimeSlice::new` signature to take `convergence: ConvergenceOptions` as the third parameter.
3. Update the production callsite in `History::add_events_with_prior` (`src/history.rs:597`) to pass `self.convergence`.
4. Update the five test callsites in `src/time_slice.rs` (lines 646, 723, 803, 901 — the four with `TimeSlice::new(0i64, 0.0)`, plus the one inside the test module's `iterate_through_color_groups` test if it exists; locate via `grep -n "TimeSlice::new" src/time_slice.rs`).
5. Rename the existing `pub(crate) fn convergence` method (at `src/time_slice.rs:447`) to `iterate_to_convergence`. Update its 4 in-file call sites.
After this task the convergence field is wired but **unused** by inference (Task 2 makes the three Game callsites read it). All existing tests must pass bit-equal because the propagated value still equals `ConvergenceOptions::default()` end-to-end.
**Files:**
- Modify: `src/time_slice.rs`
- Modify: `src/history.rs:597`
- [ ] **Step 1: Locate all `TimeSlice::new` and `convergence`-method callsites**
Run:
```bash
grep -n "TimeSlice::new\|\.convergence(" src/time_slice.rs src/history.rs
```
Expected: 1 production callsite of `TimeSlice::new` in `history.rs`, 5 test callsites in `time_slice.rs`, and 4 method-style `.convergence(` calls in `time_slice.rs` test module. (No `.convergence(` calls in `history.rs` — those are field accesses.)
Save the line numbers — you'll need them in Step 4 and Step 6.
- [ ] **Step 2: Add the `convergence` field to `TimeSlice<T>`**
In `src/time_slice.rs`, modify the `TimeSlice<T>` struct (currently at `src/time_slice.rs:172-180`):
```rust
#[derive(Debug)]
pub struct TimeSlice<T: Time = i64> {
pub(crate) events: Vec<Event>,
pub(crate) skills: SkillStore,
pub(crate) time: T,
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
arena: ScratchArena,
pub(crate) color_groups: ColorGroups,
}
```
Code won't compile until Step 3.
- [ ] **Step 3: Change `TimeSlice::new` signature**
In `src/time_slice.rs`, replace the existing `pub fn new` (currently at `src/time_slice.rs:183-192`) with:
```rust
pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
Self {
events: Vec::new(),
skills: SkillStore::new(),
time,
p_draw,
convergence,
arena: ScratchArena::new(),
color_groups: ColorGroups::new(),
}
}
```
- [ ] **Step 4: Update the production callsite in `history.rs`**
In `src/history.rs:597`, replace:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw);
```
with:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
```
- [ ] **Step 5: Update test callsites of `TimeSlice::new`**
Run `cargo build --tests` to surface every remaining compile error. Each error is a `TimeSlice::new(time, p_draw)` callsite missing the third argument. The fix: add `crate::ConvergenceOptions::default(),` (inside `src/time_slice.rs` test modules use the path relative to where `ConvergenceOptions` is in scope — if it's not imported in that test mod, add `use crate::ConvergenceOptions;` at the top of the mod and pass `ConvergenceOptions::default()`).
Example transformation. Before:
```rust
let mut time_slice = TimeSlice::new(0i64, 0.0);
```
After:
```rust
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
```
Apply to all 5 test callsites identified in Step 1. Repeat `cargo build --tests` until it succeeds.
- [ ] **Step 6: Rename the `convergence` method to `iterate_to_convergence`**
In `src/time_slice.rs`, find the method definition at `src/time_slice.rs:447`:
```rust
pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
```
Rename to:
```rust
pub(crate) fn iterate_to_convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
```
Then update the 4 call sites (located in Step 1 — `time_slice.rs:693, 755, 817, 851` or wherever your grep found them). At each site, replace `time_slice.convergence(&agents)` with `time_slice.iterate_to_convergence(&agents)`.
- [ ] **Step 7: Build and run the full test suite**
Run: `cargo build && cargo test --lib`
Expected: all 98 lib tests pass. Bit-equal goldens — the convergence field is wired but the three inference callsites still hardcode `ConvergenceOptions::default()` (Task 2 changes that), and the propagated default equals what was hardcoded before, so behavior is identical.
If any test fails: investigate. The most likely cause is a missed `TimeSlice::new` callsite or a `.convergence(` call site that needs renaming.
- [ ] **Step 8: Run integration tests**
Run: `cargo test`
Expected: all 27 integration tests still pass.
- [ ] **Step 9: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 10: Commit**
```bash
git add src/time_slice.rs src/history.rs
git commit -m "$(cat <<'EOF'
refactor(time_slice): add convergence field, rename iterate_to_convergence
TimeSlice<T> gains a pub(crate) convergence: ConvergenceOptions field
set at construction. TimeSlice::new now takes it as a third parameter
(breaking change to the pub constructor, acceptable in 0.1.x).
History::add_events_with_prior passes self.convergence so the propagated
value reaches every TimeSlice. The pre-existing convergence-the-method
is renamed to iterate_to_convergence to disambiguate from the new
convergence-the-field.
The field is wired but not yet read by inference — the three
Game::*_with_arena callsites in time_slice.rs still hardcode
ConvergenceOptions::default(). Task 2 changes that. Bit-equal because
the propagated value equals the hardcoded value end-to-end.
EOF
)"
```
---
### Task 2: Read `self.convergence` at the three inference callsites
This task switches the three `Game::*_with_arena` callsites in `time_slice.rs` from hardcoded `ConvergenceOptions::default()` to the propagated `self.convergence` (or for `Event::iteration_direct`, a passed-in parameter). After this task, Damped EP set on `HistoryBuilder` actually reaches the within-game loop.
**Files:**
- Modify: `src/time_slice.rs` (only)
- [ ] **Step 1: Add a `convergence` parameter to `Event::iteration_direct`**
In `src/time_slice.rs`, modify the existing `iteration_direct` signature (currently at `src/time_slice.rs:131-137`):
```rust
fn iteration_direct<T: Time, D: Drift<T>>(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<T, D>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) {
```
Inside the body (around `src/time_slice.rs:140-156`), replace both `crate::ConvergenceOptions::default()` arguments with `convergence`:
```rust
let g = match self.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&self.weights,
p_draw,
convergence,
arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&self.weights,
score_sigma,
convergence,
arena,
),
};
```
- [ ] **Step 2: Update the rayon path in `sweep_color_groups` (cfg=rayon)**
In `src/time_slice.rs`, the rayon-feature `sweep_color_groups` (currently at `src/time_slice.rs:376-423`) captures `p_draw` by value into a `move` closure and calls `ev.iteration_direct(skills, agents, p_draw, &mut arena)`. Capture `convergence` the same way and pass it:
Above the rayon `for_each` at the line `let p_draw = self.p_draw;`, add:
```rust
let convergence = self.convergence;
```
Then update the call inside the closure (currently `ev.iteration_direct(skills, agents, p_draw, &mut arena);`):
```rust
ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
```
The `else` branch (sequential fallback) at `src/time_slice.rs:417-421` calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` — also update:
```rust
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
```
(Note: this branch reads `self.convergence` directly because no `move` closure is involved here.)
- [ ] **Step 3: Update the non-rayon path in `sweep_color_groups`**
In `src/time_slice.rs`, the `#[cfg(not(feature = "rayon"))]` `sweep_color_groups` (currently at `src/time_slice.rs:428-444`) calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` at `src/time_slice.rs:441`. Replace with:
```rust
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
```
- [ ] **Step 4: Update `TimeSlice::iteration`'s sequential branch**
In `src/time_slice.rs`, modify `TimeSlice::iteration` (at `src/time_slice.rs:322-363`). The sequential branch (when `from > 0 || self.color_groups.is_empty()`) has two `Game::*_with_arena` callsites at `src/time_slice.rs:330-346` that hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
```rust
let g = match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
self.convergence,
&mut self.arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
&mut self.arena,
),
};
```
- [ ] **Step 5: Update `TimeSlice::log_evidence`**
In `src/time_slice.rs`, modify `TimeSlice::log_evidence` (at `src/time_slice.rs:505-540`). The two `Game::*_with_arena` callsites in the inner `run_event` closure at `src/time_slice.rs:519-538` hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
```rust
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
let teams = event.within_priors(online, forward, &self.skills, agents);
let result = event.outputs();
match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
self.convergence,
arena,
)
.evidence
.ln(),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
arena,
)
.evidence
.ln(),
}
};
```
(`self.convergence` is `Copy`, so the closure captures it by value naturally without needing a `let` binding outside.)
- [ ] **Step 6: Build and run the full test suite — bit-equal regression net**
Run: `cargo build && cargo test --lib`
Expected: all 98 lib tests still pass. Bit-equal goldens — every existing test uses `History::default()` or `HistoryBuilder::default()` (which sets `convergence = ConvergenceOptions::default()`), so the propagated value equals what the hardcoded default was. No test exercises a non-default convergence through History today, so no behavior changes.
If any test fails: investigate. The most likely cause is a stale `crate::ConvergenceOptions::default()` call missed in steps 1-5 — re-grep with `grep -n "ConvergenceOptions::default" src/time_slice.rs` to find any remaining hardcoded sites.
- [ ] **Step 7: Run integration tests**
Run: `cargo test`
Expected: all 27 integration tests still pass.
- [ ] **Step 8: Confirm no `crate::ConvergenceOptions::default()` remains in time_slice.rs**
Run: `grep -n "ConvergenceOptions::default" src/time_slice.rs`
Expected: only test-mod hits (in `TimeSlice::new(0i64, 0.0, ConvergenceOptions::default())` callsites from Task 1 step 5). NO production-code hits in `Event::iteration_direct`, `sweep_color_groups`, `TimeSlice::iteration`, or `TimeSlice::log_evidence`.
- [ ] **Step 9: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 10: Commit**
```bash
git add src/time_slice.rs
git commit -m "$(cat <<'EOF'
feat(time_slice): inference callsites read self.convergence
The three Game::*_with_arena callsites in time_slice.rs (in
TimeSlice::iteration's sequential branch, TimeSlice::log_evidence's
run_event closure, and Event::iteration_direct via parameter) now use
the propagated ConvergenceOptions instead of hardcoded ::default().
sweep_color_groups (both rayon and non-rayon paths) forwards
self.convergence into Event::iteration_direct.
Damped EP (alpha < 1.0) and custom max_iter / epsilon set on
HistoryBuilder::convergence(opts) now actually reach the within-game
inference loop. Bit-equal for users on default options.
EOF
)"
```
---
### Task 3: Doc-comment update + end-to-end integration tests
**Files:**
- Modify: `src/convergence.rs` (alpha doc comment)
- Modify: `src/history.rs` (two integration tests in the existing `#[cfg(test)] mod tests` block)
- [ ] **Step 1: Update `ConvergenceOptions::alpha` doc comment**
In `src/convergence.rs`, find the existing doc comment on the `alpha` field. Replace it with:
```rust
/// 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,
```
- [ ] **Step 2: Locate the `#[cfg(test)] mod tests` block in `src/history.rs`**
Run: `grep -n "#\[cfg(test)\]" src/history.rs`
Identify the test module (there should be one near the bottom of the file). Read the imports at the top of that module so the new tests can reuse the existing test helpers and scope.
- [ ] **Step 3: Write the failing tests**
Add the following two tests at the end of the test module in `src/history.rs` (just before the module's closing `}`):
```rust
#[test]
fn history_propagates_convergence_to_inner_run_chain() {
use crate::ConvergenceOptions;
// 4-team ranked game; each event needs more than one inner EP iter
// to fully converge.
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
crate::observer::NullObserver, &'static str>| {
for &name in &["a", "b", "c", "d"] {
h.new_agent(name);
}
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.commit()
.unwrap();
};
let mut h_capped = crate::History::builder()
.convergence(ConvergenceOptions {
max_iter: 1,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_capped);
h_capped.converge().unwrap();
let mut h_full = crate::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 crate::History<i64, crate::drift::ConstantDrift,
crate::observer::NullObserver, &'static str>| {
for &name in &["a", "b", "c", "d"] {
h.new_agent(name);
}
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.commit()
.unwrap();
};
let mut h_undamped = crate::History::builder().build();
events_for(&mut h_undamped);
h_undamped.converge().unwrap();
let mut h_damped = crate::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}"
);
}
```
If the import or method names (e.g. `History::builder()`, `event(...).team(...).commit()`, `learning_curves()`, `new_agent(...)`) don't match what's available in the test module, look at neighboring tests for the exact builder/event-construction pattern in current use and mirror it. The structure (build two Histories, add identical events, compare curves) is the contract; the surface syntax must follow what already works in this test file.
- [ ] **Step 4: Run the new tests**
Run: `cargo test --lib history_propagates_convergence_to_inner_run_chain history_with_damping_reaches_same_fixed_point_as_undamped`
Expected: 2 passed.
**Fallback if Test 1 fails** (`max_iter=1` produces the same posteriors as default — meaning the inner loop converges in one iteration on this graph): replace `max_iter: 1` with `max_iter: 0`. With `max_iter = 0` the inner loop body runs zero times, guaranteeing different posteriors than convergence.
**Fallback if Test 2 fails** (`max_diff` exceeds `1e-3`): raise `max_iter: 200` to `max_iter: 500`. Heavier damping needs more iterations to reach the same fixed point.
If neither fallback works, STOP and report BLOCKED with the actual `max_diff` and the iteration counts tried.
- [ ] **Step 5: Run the full test suite**
Run: `cargo test --lib && cargo test`
Expected: lib count = 100 (was 98), integration count = 27 (unchanged), all passing.
- [ ] **Step 6: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 7: Commit**
```bash
git add src/convergence.rs src/history.rs
git commit -m "$(cat <<'EOF'
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.
EOF
)"
```
---
## Self-review (writer's note)
**Spec coverage:**
- Spec § "What ships" item 1 (TimeSlice convergence field) → Task 1 step 2 ✓
- Spec § "What ships" item 2 (TimeSlice::new signature) → Task 1 step 3 ✓
- Spec § "What ships" item 3 (History passes self.convergence) → Task 1 step 4 ✓
- Spec § "What ships" item 4 (Event::iteration_direct gains parameter) → Task 2 step 1 ✓
- Spec § "What ships" item 4 (callers pass self.convergence) → Task 2 steps 2, 3 ✓
- Spec § "What ships" item 5 (TimeSlice::convergence-method reads field) → Task 2 step 4 ✓
- Spec § "What ships" item 6 (log_evidence reads field) → Task 2 step 5 ✓
- Spec § "What ships" item 7 (test callsite updates) → Task 1 step 5 ✓
- Spec § "Design" rename method → Task 1 step 6 ✓
- Spec § "Risks" alpha doc-comment update → Task 3 step 1 ✓
- Spec § "Testing strategy" §1 (regression net) → Tasks 1 step 7, 2 step 6, 3 step 5 ✓
- Spec § "Testing strategy" §2 (history_propagates_convergence) → Task 3 step 3 test 1 ✓
- Spec § "Testing strategy" §2 (history_with_damping_reaches_same_fixed_point) → Task 3 step 3 test 2 ✓
**Out-of-scope items correctly absent:** No new `History`/`HistoryBuilder` methods, no `ConvergenceOptions` split, no `Damped` Schedule impl, no nat-param convergence switch.
**Type / signature consistency:**
- `TimeSlice::new(time, p_draw, convergence: ConvergenceOptions)` — Task 1 step 3 (def) and Task 1 step 4-5 (call sites) match ✓
- `iteration_direct(skills, agents, p_draw, convergence, arena)` — Task 2 step 1 (def) and steps 2, 3 (call sites) match ✓
- `iterate_to_convergence` — Task 1 step 6 ✓
- All `self.convergence` reads are field accesses, not method calls (the rename in Task 1 step 6 prevents ambiguity) ✓
**Two tasks (1 and 2) split rationale:** Task 1 wires the field but the inference path still uses hardcoded defaults (no behavioral change). Task 2 makes the field actually drive inference (behavioral change for non-default users). Each task is independently committable and the test suite is bit-equal at every checkpoint.
**No placeholders detected.**
@@ -0,0 +1,232 @@
# 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 `TimeSlice`s, 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`:
```rust
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
1. `TimeSlice<T>` gains a `pub(crate) convergence: ConvergenceOptions`
field set at construction.
2. `TimeSlice::new` signature becomes
`pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self`.
3. `History::add_events_with_prior` (`history.rs:597`) passes
`self.convergence` when constructing new `TimeSlice`s.
4. `Event::iteration_direct` gains a `convergence: ConvergenceOptions`
parameter and forwards it to the `Game::*_with_arena` callsite.
The two callers (`TimeSlice::iteration` at `time_slice.rs:419` and
`:441`) pass `self.convergence`.
5. `TimeSlice::convergence` (the method, not the field) replaces its
hardcoded `crate::ConvergenceOptions::default()` with
`self.convergence`.
6. `TimeSlice::log_evidence` does the same.
7. Five test callsites of `TimeSlice::new(time, p_draw)` updated
mechanically to `TimeSlice::new(time, p_draw, ConvergenceOptions::default())`.
### What does not ship
- No split of `ConvergenceOptions` into outer/inner fields. The
conflation (one `max_iter` covers both the cross-history sweep and
the per-game EP iteration cap) is the user-confirmed design.
- No `Damped` impl in `src/schedule.rs`. The `Schedule` trait is still
not integrated into `run_chain`.
- No nat-param convergence switch.
- No oscillation auto-detect.
- No new `History` or `HistoryBuilder` methods. `convergence(opts)`
already exists and works.
- No changes to `History::converge` — the outer-loop semantics are
unchanged (it already reads `self.convergence`).
## Design
### `TimeSlice<T>` field
```rust
// src/time_slice.rs
pub struct TimeSlice<T: Time = i64> {
// ... existing fields ...
p_draw: f64,
pub(crate) convergence: ConvergenceOptions,
// ... existing fields ...
}
```
### `TimeSlice::new`
```rust
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`:
```rust
// 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
```rust
// 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
1. **`history_propagates_convergence_to_inner_run_chain`** (in
`src/history.rs` test 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.
2. **`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.
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
```bash
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::new` is `pub`.** 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::convergence` method rename.** Renaming
`convergence``iterate_to_convergence` touches `history.rs` and 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.5` on
a `History` gets damping inside every per-game loop, but the outer
`History::converge` sweep is undamped. This is the correct semantic
(alpha is a within-EP-graph concept) but it's worth documenting in
the `ConvergenceOptions::alpha` doc comment so users don't expect
cross-slice damping. Add one sentence to the existing doc comment.
## Out-of-scope follow-ups
- Wire `Schedule` trait into `run_chain` — Damped becomes a `Schedule`
impl alongside `EpsilonOrMax`.
- Per-loop `ConvergenceOptions` split (outer / inner).
- `Residual` schedule.
- Per-event `EventKind::Scored.score_sigma` override (still
history-wide today).
+8 -3
View File
@@ -9,9 +9,14 @@ pub struct ConvergenceOptions {
pub max_iter: usize, pub max_iter: usize,
pub epsilon: f64, pub epsilon: f64,
/// EP damping factor in natural-parameter space: each per-factor /// EP damping factor in natural-parameter space: each per-factor
/// update writes `α·new + (1−α)·old`. `1.0` is undamped (default); /// update inside a single game writes `α·new + (1−α)·old`. `1.0` is
/// `< 1.0` stabilises oscillating fixed-point loops at the cost of /// undamped (default); `< 1.0` stabilises oscillating fixed-point
/// more iterations. Must be in `(0.0, 1.0]`. /// 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, pub alpha: f64,
} }
+94 -1
View File
@@ -594,7 +594,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
agent.message = time_slice.forward_prior_out(&agent_idx); agent.message = time_slice.forward_prior_out(&agent_idx);
} }
} else { } else {
let mut time_slice = TimeSlice::new(t, self.p_draw); let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents); time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents);
self.time_slices.insert(k, time_slice); self.time_slices.insert(k, time_slice);
@@ -1714,4 +1714,97 @@ mod tests {
fn history_builder_rejects_zero_score_sigma() { fn history_builder_rejects_zero_score_sigma() {
let _ = History::builder().score_sigma(0.0).build(); 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<i64, ConstantDrift, crate::observer::NullObserver, &'static str>| {
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.ranking([0u32, 1, 2, 3])
.commit()
.unwrap();
};
let mut h_capped: History<i64, _, _, &'static str> = History::builder()
.convergence(ConvergenceOptions {
max_iter: 1,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_capped);
h_capped.converge().unwrap();
let mut h_full: History<i64, _, _, &'static str> = 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<i64, ConstantDrift, crate::observer::NullObserver, &'static str>| {
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.ranking([0u32, 1, 2, 3])
.commit()
.unwrap();
};
let mut h_undamped: History<i64, _, _, &'static str> = History::builder().build();
events_for(&mut h_undamped);
h_undamped.converge().unwrap();
let mut h_damped: History<i64, _, _, &'static str> = 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}"
);
}
} }
+40 -26
View File
@@ -133,25 +133,21 @@ impl Event {
skills: &mut SkillStore, skills: &mut SkillStore,
agents: &CompetitorStore<T, D>, agents: &CompetitorStore<T, D>,
p_draw: f64, p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena, arena: &mut ScratchArena,
) { ) {
let teams = self.within_priors(false, false, skills, agents); let teams = self.within_priors(false, false, skills, agents);
let result = self.outputs(); let result = self.outputs();
let g = match self.kind { let g = match self.kind {
EventKind::Ranked => Game::ranked_with_arena( EventKind::Ranked => {
teams, Game::ranked_with_arena(teams, &result, &self.weights, p_draw, convergence, arena)
&result, }
&self.weights,
p_draw,
crate::ConvergenceOptions::default(),
arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena( EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams, teams,
&result, &result,
&self.weights, &self.weights,
score_sigma, score_sigma,
crate::ConvergenceOptions::default(), convergence,
arena, arena,
), ),
}; };
@@ -175,17 +171,19 @@ pub struct TimeSlice<T: Time = i64> {
pub(crate) skills: SkillStore, pub(crate) skills: SkillStore,
pub(crate) time: T, pub(crate) time: T,
p_draw: f64, p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
arena: ScratchArena, arena: ScratchArena,
pub(crate) color_groups: ColorGroups, pub(crate) color_groups: ColorGroups,
} }
impl<T: Time> TimeSlice<T> { impl<T: Time> TimeSlice<T> {
pub fn new(time: T, p_draw: f64) -> Self { pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
Self { Self {
events: Vec::new(), events: Vec::new(),
skills: SkillStore::new(), skills: SkillStore::new(),
time, time,
p_draw, p_draw,
convergence,
arena: ScratchArena::new(), arena: ScratchArena::new(),
color_groups: ColorGroups::new(), color_groups: ColorGroups::new(),
} }
@@ -332,7 +330,7 @@ impl<T: Time> TimeSlice<T> {
&result, &result,
&event.weights, &event.weights,
self.p_draw, self.p_draw,
crate::ConvergenceOptions::default(), self.convergence,
&mut self.arena, &mut self.arena,
), ),
EventKind::Scored { score_sigma } => Game::scored_with_arena( EventKind::Scored { score_sigma } => Game::scored_with_arena(
@@ -340,7 +338,7 @@ impl<T: Time> TimeSlice<T> {
&result, &result,
&event.weights, &event.weights,
score_sigma, score_sigma,
crate::ConvergenceOptions::default(), self.convergence,
&mut self.arena, &mut self.arena,
), ),
}; };
@@ -394,6 +392,7 @@ impl<T: Time> TimeSlice<T> {
} }
let range = self.color_groups.color_range(color_idx); let range = self.color_groups.color_range(color_idx);
let p_draw = self.p_draw; let p_draw = self.p_draw;
let convergence = self.convergence;
if group_len >= RAYON_THRESHOLD { if group_len >= RAYON_THRESHOLD {
// Obtain a raw pointer from the unique `&mut self.skills` reference. // Obtain a raw pointer from the unique `&mut self.skills` reference.
@@ -411,12 +410,18 @@ impl<T: Time> TimeSlice<T> {
ARENA.with(|cell| { ARENA.with(|cell| {
let mut arena = cell.borrow_mut(); let mut arena = cell.borrow_mut();
arena.reset(); arena.reset();
ev.iteration_direct(skills, agents, p_draw, &mut arena); ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
}); });
}); });
} else { } else {
for ev in &mut self.events[range] { for ev in &mut self.events[range] {
ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena); ev.iteration_direct(
&mut self.skills,
agents,
p_draw,
self.convergence,
&mut self.arena,
);
} }
} }
} }
@@ -438,13 +443,22 @@ impl<T: Time> TimeSlice<T> {
// allowed within a single method body. // allowed within a single method body.
let p_draw = self.p_draw; let p_draw = self.p_draw;
for ev in &mut self.events[range] { for ev in &mut self.events[range] {
ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena); ev.iteration_direct(
&mut self.skills,
agents,
p_draw,
self.convergence,
&mut self.arena,
);
} }
} }
} }
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize { pub(crate) fn iterate_to_convergence<D: Drift<T>>(
&mut self,
agents: &CompetitorStore<T, D>,
) -> usize {
let epsilon = 1e-6; let epsilon = 1e-6;
let iterations = 20; let iterations = 20;
@@ -521,7 +535,7 @@ impl<T: Time> TimeSlice<T> {
&result, &result,
&event.weights, &event.weights,
self.p_draw, self.p_draw,
crate::ConvergenceOptions::default(), self.convergence,
arena, arena,
) )
.evidence .evidence
@@ -531,7 +545,7 @@ impl<T: Time> TimeSlice<T> {
&result, &result,
&event.weights, &event.weights,
score_sigma, score_sigma,
crate::ConvergenceOptions::default(), self.convergence,
arena, arena,
) )
.evidence .evidence
@@ -643,7 +657,7 @@ mod tests {
); );
} }
let mut time_slice = TimeSlice::new(0i64, 0.0); let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
time_slice.add_events( time_slice.add_events(
vec![ vec![
@@ -690,7 +704,7 @@ mod tests {
epsilon = 1e-6 epsilon = 1e-6
); );
assert_eq!(time_slice.convergence(&agents), 1); assert_eq!(time_slice.iterate_to_convergence(&agents), 1);
} }
#[test] #[test]
@@ -720,7 +734,7 @@ mod tests {
); );
} }
let mut time_slice = TimeSlice::new(0i64, 0.0); let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
time_slice.add_events( time_slice.add_events(
vec![ vec![
@@ -752,7 +766,7 @@ mod tests {
epsilon = 1e-6 epsilon = 1e-6
); );
assert!(time_slice.convergence(&agents) > 1); assert!(time_slice.iterate_to_convergence(&agents) > 1);
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -800,7 +814,7 @@ mod tests {
); );
} }
let mut time_slice = TimeSlice::new(0i64, 0.0); let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
time_slice.add_events( time_slice.add_events(
vec![ vec![
@@ -814,7 +828,7 @@ mod tests {
&agents, &agents,
); );
time_slice.convergence(&agents); time_slice.iterate_to_convergence(&agents);
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -848,7 +862,7 @@ mod tests {
assert_eq!(time_slice.events.len(), 6); assert_eq!(time_slice.events.len(), 6);
time_slice.convergence(&agents); time_slice.iterate_to_convergence(&agents);
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -898,7 +912,7 @@ mod tests {
); );
} }
let mut ts = TimeSlice::new(0i64, 0.0); let mut ts = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
ts.add_events( ts.add_events(
vec![ vec![