Files
trueskill-tt/docs/superpowers/plans/2026-05-08-damped-schedule.md
logaritmisk 43cc6d82f9 docs: implementation plan for game-local Damped EP
Six tasks: Gaussian::damp_natural helper, ConvergenceOptions::alpha
field, TruncFactor and MarginFactor propagate_with_alpha pair, DiffFactor
+ Game integration (the big task — must land atomically), and
end-to-end tests for max_iter and alpha behavior.
2026-05-08 14:57:41 +02:00

1089 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Damped EP 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:** Add an opt-in `ConvergenceOptions::alpha` knob that damps within-game EP message updates, plumbed through `run_chain` to `TruncFactor` and `MarginFactor` via a new `Gaussian::damp_natural` helper. `alpha = 1.0` (default) keeps every existing golden bit-equal.
**Architecture:** Game-local. The `Schedule` trait at `src/schedule.rs` is **not** touched — it's shipped infrastructure that `run_chain` doesn't currently call. Damping is implemented directly inside `TruncFactor::propagate_with_alpha` / `MarginFactor::propagate_with_alpha` (inherent methods, not the public `Factor` trait). `DiffFactor::propagate` (game-private) gains an `alpha` parameter and dispatches into them. `Game<'a, T, D>` and `OwnedGame<T, D>` gain a `convergence: ConvergenceOptions` field set at construction; `run_chain` reads `self.convergence.{epsilon, max_iter, alpha}` instead of hardcoded values.
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
---
## Spec reference
`docs/superpowers/specs/2026-05-08-damped-schedule-design.md`
## File map
| File | Why touched |
|---|---|
| `src/gaussian.rs` | Add `damp_natural` helper + tests |
| `src/convergence.rs` | Add `alpha: f64` field to `ConvergenceOptions` |
| `src/factor/trunc.rs` | Add inherent `propagate_with_alpha`; trait `propagate` delegates with `alpha=1.0` |
| `src/factor/margin.rs` | Same shape as `trunc.rs` |
| `src/game.rs` | `DiffFactor::propagate` gains `alpha`; `Game`/`OwnedGame` gain `convergence` field; `ranked_with_arena`/`scored_with_arena` accept it; `run_chain` reads it; ~20 test callsites updated |
## Pre-flight context for the implementer
- `Gaussian` stores natural parameters `pi = 1/sigma²` and `tau = mu/sigma²`. The accessors are `pi()`, `tau()`. Constructor `Gaussian::from_natural(pi, tau)` is `pub(crate)`.
- `Gaussian::Mul<f64>` already exists but it is **distribution scaling** (`sigma → sigma·|scalar|`), NOT nat-param scaling. Do not reuse it for damping.
- `Factor::propagate(&mut self, vars: &mut VarStore) -> (f64, f64)` is the public trait method. Keep it intact (signature must not change) — `Schedule::run` depends on it.
- `DiffFactor` (`src/game.rs:20-54`) is `pub(crate)`. Its current signature is `propagate(&mut self, vars: &mut VarStore) -> (f64, f64)`. Adding `alpha: f64` here is fine — only `run_chain` calls it.
- `ConvergenceOptions` lives at `src/convergence.rs`. It is `Copy + Clone + Debug`. Keep it `Copy`.
- `run_chain` body (`src/game.rs:236-348`) currently hardcodes `tuple_gt(step, 1e-6) && iter < 10`. Three call sites use `lf.propagate(&mut arena.vars)` — forward sweep, backward sweep, and the `n_diffs == 1` special case.
- `Game<'a, T, D>` (`src/game.rs:148-156`) and `OwnedGame<T, D>` (`src/game.rs:83-92`) both have public-API entry points (`Game::ranked`, `Game::scored`, `Game::free_for_all`, `Game::one_v_one`). These take `&GameOptions`. The `convergence` field on `GameOptions` is silently dropped today by `OwnedGame::new` / `OwnedGame::new_scored` — fixing this is part of the work.
- All ~20 `ranked_with_arena` / `scored_with_arena` callsites are in tests inside `src/game.rs`. None are in `tests/` integration files.
---
### Task 1: Add `Gaussian::damp_natural` helper
**Files:**
- Modify: `src/gaussian.rs` (add method to existing `impl Gaussian` block + tests in the existing `#[cfg(test)] mod tests`)
- [ ] **Step 1: Write the failing tests**
In `src/gaussian.rs`, find the existing `#[cfg(test)] mod tests { ... }` block (near the end of the file). Add these three tests at the end of that module:
```rust
#[test]
fn damp_natural_alpha_one_returns_new() {
let old = Gaussian::from_ms(1.0, 2.0);
let new = Gaussian::from_ms(5.0, 0.5);
let damped = old.damp_natural(new, 1.0);
assert_eq!(damped.pi(), new.pi());
assert_eq!(damped.tau(), new.tau());
}
#[test]
fn damp_natural_alpha_zero_returns_self() {
let old = Gaussian::from_ms(1.0, 2.0);
let new = Gaussian::from_ms(5.0, 0.5);
let damped = old.damp_natural(new, 0.0);
assert_eq!(damped.pi(), old.pi());
assert_eq!(damped.tau(), old.tau());
}
#[test]
fn damp_natural_alpha_half_is_midpoint_in_natural_params() {
let old = Gaussian::from_ms(1.0, 2.0);
let new = Gaussian::from_ms(5.0, 0.5);
let damped = old.damp_natural(new, 0.5);
let expected_pi = 0.5 * new.pi() + 0.5 * old.pi();
let expected_tau = 0.5 * new.tau() + 0.5 * old.tau();
assert!((damped.pi() - expected_pi).abs() < 1e-12);
assert!((damped.tau() - expected_tau).abs() < 1e-12);
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test --lib gaussian::tests::damp_natural`
Expected: 3 errors, all of the form "no method named `damp_natural` found for struct `Gaussian`".
- [ ] **Step 3: Implement `damp_natural`**
In `src/gaussian.rs`, find the existing `impl Gaussian { ... }` block (the one containing `from_ms`, `from_natural`, `pi()`, `tau()`, `mu()`, `sigma()`). Add this method at the end of that impl block, immediately before the closing `}`:
```rust
/// EP damping in natural-parameter space: `α·new + (1−α)·self`.
///
/// Used by within-game inference to stabilise oscillating fixed-point
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
/// `alpha < 1.0` shrinks each per-step update.
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
Gaussian::from_natural(
alpha * new.pi() + (1.0 - alpha) * self.pi(),
alpha * new.tau() + (1.0 - alpha) * self.tau(),
)
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `cargo test --lib gaussian::tests::damp_natural`
Expected: 3 passed.
- [ ] **Step 5: Run the full test suite to confirm no regression**
Run: `cargo test --lib`
Expected: all passing, total count = previous baseline + 3.
- [ ] **Step 6: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 7: Commit**
```bash
git add src/gaussian.rs
git commit -m "$(cat <<'EOF'
feat(gaussian): add damp_natural helper for EP damping
Computes α·new + (1−α)·self in natural-parameter space. Will be used
by TruncFactor and MarginFactor to support opt-in EP damping via
ConvergenceOptions::alpha.
EOF
)"
```
---
### Task 2: Add `ConvergenceOptions::alpha` field
**Files:**
- Modify: `src/convergence.rs` (add field + update Default; add test)
- [ ] **Step 1: Write the failing test**
In `src/convergence.rs`, add a `#[cfg(test)] mod tests` block at the end of the file (the file currently has none):
```rust
#[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);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cargo test --lib convergence::tests::default_alpha_is_one`
Expected: compile error "no field `alpha` on type `ConvergenceOptions`".
- [ ] **Step 3: Add the field**
In `src/convergence.rs`, modify the existing `ConvergenceOptions` struct and its `Default` impl:
```rust
#[derive(Clone, Copy, Debug)]
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]`.
pub alpha: f64,
}
impl Default for ConvergenceOptions {
fn default() -> Self {
Self {
max_iter: crate::ITERATIONS,
epsilon: crate::EPSILON,
alpha: 1.0,
}
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `cargo test --lib convergence::tests::default_alpha_is_one`
Expected: 1 passed.
- [ ] **Step 5: Confirm full suite still compiles**
Run: `cargo test --lib`
Expected: all passing. The new field has a `Default` value so existing `..Default::default()` constructions in tests continue to work without modification.
- [ ] **Step 6: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 7: Commit**
```bash
git add src/convergence.rs
git commit -m "$(cat <<'EOF'
feat(convergence): add ConvergenceOptions::alpha damping field
Adds an EP damping coefficient defaulting to 1.0 (undamped). Will be
read by run_chain in a follow-up commit. By itself this commit changes
no behavior — existing constructors using ..Default::default() pick up
the new field automatically.
EOF
)"
```
---
### Task 3: `TruncFactor::propagate_with_alpha`
**Files:**
- Modify: `src/factor/trunc.rs` (add inherent method; trait impl delegates)
The current `TruncFactor::propagate` body computes `cavity`, `trunc`, `new_msg`, then writes `vars.set(diff, trunc)`. The damped version writes `vars.set(diff, cavity * damped)` — which equals `trunc` when `alpha = 1.0` (since `cavity * new_msg = trunc` by construction) but reflects partial-update math otherwise.
- [ ] **Step 1: Write the failing test**
In `src/factor/trunc.rs`, inside the existing `#[cfg(test)] mod tests` block, add:
```rust
#[test]
fn propagate_with_alpha_one_matches_undamped_propagate() {
let mut vars_a = VarStore::new();
let diff_a = vars_a.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_a = TruncFactor::new(diff_a, 0.0, false);
let delta_a = f_a.propagate(&mut vars_a);
let result_a = vars_a.get(diff_a);
let mut vars_b = VarStore::new();
let diff_b = vars_b.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_b = TruncFactor::new(diff_b, 0.0, false);
let delta_b = f_b.propagate_with_alpha(&mut vars_b, 1.0);
let result_b = vars_b.get(diff_b);
assert_eq!(result_a.pi(), result_b.pi());
assert_eq!(result_a.tau(), result_b.tau());
assert_eq!(delta_a, delta_b);
assert_eq!(f_a.msg.pi(), f_b.msg.pi());
assert_eq!(f_a.msg.tau(), f_b.msg.tau());
}
#[test]
fn propagate_with_alpha_half_blends_msg_in_natural_params() {
// Run undamped to capture (initial_msg, undamped_new_msg).
let mut vars_full = VarStore::new();
let diff_full = vars_full.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_full = TruncFactor::new(diff_full, 0.0, false);
let initial_msg_pi = f_full.msg.pi();
let initial_msg_tau = f_full.msg.tau();
f_full.propagate(&mut vars_full);
let undamped_msg_pi = f_full.msg.pi();
let undamped_msg_tau = f_full.msg.tau();
// Run damped at α = 0.5 from the same initial state.
let mut vars_half = VarStore::new();
let diff_half = vars_half.alloc(Gaussian::from_ms(2.0, 3.0));
let mut f_half = TruncFactor::new(diff_half, 0.0, false);
f_half.propagate_with_alpha(&mut vars_half, 0.5);
let expected_pi = 0.5 * undamped_msg_pi + 0.5 * initial_msg_pi;
let expected_tau = 0.5 * undamped_msg_tau + 0.5 * initial_msg_tau;
assert!((f_half.msg.pi() - expected_pi).abs() < 1e-12);
assert!((f_half.msg.tau() - expected_tau).abs() < 1e-12);
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test --lib factor::trunc::tests::propagate_with_alpha`
Expected: 2 errors, "no method named `propagate_with_alpha` found".
- [ ] **Step 3: Implement `propagate_with_alpha` and rewire trait impl**
In `src/factor/trunc.rs`, replace the existing `impl Factor for TruncFactor { ... }` block (currently lines 36-64 — the entire `impl Factor` block) with:
```rust
impl TruncFactor {
/// Propagate this factor's message, optionally damping the update in
/// natural-parameter space. `alpha = 1.0` matches `Factor::propagate`
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(
&mut self,
vars: &mut VarStore,
alpha: f64,
) -> (f64, f64) {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
}
let trunc = approx(cavity, self.margin, self.tie);
let new_msg = trunc / cavity;
let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg;
self.msg = damped;
// marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
// `trunc` (since cavity * new_msg = trunc by construction); with
// alpha < 1.0 it reflects the partially-applied update.
vars.set(self.diff, cavity * damped);
old_msg.delta(damped)
}
}
impl Factor for TruncFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
self.propagate_with_alpha(vars, 1.0)
}
fn log_evidence(&self, _vars: &VarStore) -> f64 {
self.evidence_cached.unwrap_or(1.0).ln()
}
}
```
- [ ] **Step 4: Run the new tests**
Run: `cargo test --lib factor::trunc::tests::propagate_with_alpha`
Expected: 2 passed.
- [ ] **Step 5: Run the full library test suite**
Run: `cargo test --lib`
Expected: all passing. Critically, the existing `idempotent_after_convergence`, `evidence_cached_on_first_propagate`, and `tie_evidence_uses_two_sided` tests in `src/factor/trunc.rs` must still pass — they exercise the trait `propagate` which now goes through `propagate_with_alpha(_, 1.0)`. Bit-equal because `cavity * new_msg = trunc` by construction.
- [ ] **Step 6: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
- [ ] **Step 7: Commit**
```bash
git add src/factor/trunc.rs
git commit -m "$(cat <<'EOF'
feat(factor): add TruncFactor::propagate_with_alpha for EP damping
Inherent method that applies α-damping to the outgoing message via
Gaussian::damp_natural. The Factor trait impl delegates with α=1.0,
preserving today's behavior bit-equal. Variable write switched from
`trunc` to `cavity * damped` — algebraically identical when α=1.0
(cavity * new_msg = trunc by construction); reflects partial-update
math when α<1.0.
EOF
)"
```
---
### Task 4: `MarginFactor::propagate_with_alpha`
**Files:**
- Modify: `src/factor/margin.rs` (mirror Task 3's shape)
- [ ] **Step 1: Write the failing test**
In `src/factor/margin.rs`, inside the existing `#[cfg(test)] mod tests` block, add:
```rust
#[test]
fn propagate_with_alpha_one_matches_undamped_propagate() {
let mut vars_a = VarStore::new();
let diff_a = vars_a.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_a = MarginFactor::new(diff_a, 5.0, 1.0);
let delta_a = f_a.propagate(&mut vars_a);
let result_a = vars_a.get(diff_a);
let mut vars_b = VarStore::new();
let diff_b = vars_b.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_b = MarginFactor::new(diff_b, 5.0, 1.0);
let delta_b = f_b.propagate_with_alpha(&mut vars_b, 1.0);
let result_b = vars_b.get(diff_b);
assert_eq!(result_a.pi(), result_b.pi());
assert_eq!(result_a.tau(), result_b.tau());
assert_eq!(delta_a, delta_b);
assert_eq!(f_a.msg.pi(), f_b.msg.pi());
assert_eq!(f_a.msg.tau(), f_b.msg.tau());
}
#[test]
fn propagate_with_alpha_half_blends_msg_in_natural_params() {
// Run undamped to capture (initial_msg, undamped_new_msg).
let mut vars_full = VarStore::new();
let diff_full = vars_full.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_full = MarginFactor::new(diff_full, 5.0, 1.0);
let initial_msg_pi = f_full.msg.pi();
let initial_msg_tau = f_full.msg.tau();
f_full.propagate(&mut vars_full);
let undamped_msg_pi = f_full.msg.pi();
let undamped_msg_tau = f_full.msg.tau();
// Run damped at α = 0.5 from the same initial state.
let mut vars_half = VarStore::new();
let diff_half = vars_half.alloc(Gaussian::from_ms(0.0, 6.0));
let mut f_half = MarginFactor::new(diff_half, 5.0, 1.0);
f_half.propagate_with_alpha(&mut vars_half, 0.5);
let expected_pi = 0.5 * undamped_msg_pi + 0.5 * initial_msg_pi;
let expected_tau = 0.5 * undamped_msg_tau + 0.5 * initial_msg_tau;
assert!((f_half.msg.pi() - expected_pi).abs() < 1e-12);
assert!((f_half.msg.tau() - expected_tau).abs() < 1e-12);
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test --lib factor::margin::tests::propagate_with_alpha`
Expected: 2 errors, "no method named `propagate_with_alpha` found".
- [ ] **Step 3: Implement and rewire**
In `src/factor/margin.rs`, replace the existing `impl Factor for MarginFactor { ... }` block (currently lines 35-56) with:
```rust
impl MarginFactor {
/// Propagate this factor's message, optionally damping the update in
/// natural-parameter space. `alpha = 1.0` matches `Factor::propagate`
/// exactly; `alpha < 1.0` writes `α·new_msg + (1−α)·old_msg`.
pub(crate) fn propagate_with_alpha(
&mut self,
vars: &mut VarStore,
alpha: f64,
) -> (f64, f64) {
let marginal = vars.get(self.diff);
let cavity = marginal / self.msg;
if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.m_obs, self.sigma));
}
let new_msg = Gaussian::from_ms(self.m_obs, self.sigma);
let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg;
self.msg = damped;
vars.set(self.diff, cavity * damped);
old_msg.delta(damped)
}
}
impl Factor for MarginFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) {
self.propagate_with_alpha(vars, 1.0)
}
fn log_evidence(&self, _vars: &VarStore) -> f64 {
self.evidence_cached.unwrap_or(1.0).ln()
}
}
```
- [ ] **Step 4: Run the new tests**
Run: `cargo test --lib factor::margin::tests::propagate_with_alpha`
Expected: 2 passed.
- [ ] **Step 5: Run the full library test suite**
Run: `cargo test --lib`
Expected: all passing. The existing `first_propagate_writes_tilted_marginal`, `converges_in_one_step`, `evidence_cached_on_first_propagate`, `log_evidence_matches_cached_ln` tests must still pass (they go through the trait `propagate`, which now calls `propagate_with_alpha(_, 1.0)`; bit-equal because the marginal write reduces to the original `cavity * new_msg` form when α=1.0).
- [ ] **Step 6: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
- [ ] **Step 7: Commit**
```bash
git add src/factor/margin.rs
git commit -m "$(cat <<'EOF'
feat(factor): add MarginFactor::propagate_with_alpha for EP damping
Mirrors TruncFactor: inherent damped-propagate method, trait impl
delegates with α=1.0. Existing goldens unchanged because cavity*new_msg
equals the previous marginal write when α=1.0.
EOF
)"
```
---
### Task 5: Plumb `ConvergenceOptions` through `Game` to `run_chain` and add `alpha` to `DiffFactor::propagate`
This is the integration task. It does five things, all required atomically for the suite to compile:
1. Add `convergence: ConvergenceOptions` field to both `OwnedGame<T, D>` and `Game<'a, T, D>`.
2. Extend `OwnedGame::new` and `OwnedGame::new_scored` to accept `convergence: ConvergenceOptions`. Update `Game::ranked` and `Game::scored` to forward `options.convergence`.
3. Extend `Game::ranked_with_arena` and `Game::scored_with_arena` to accept `convergence: ConvergenceOptions`. Store on `self`.
4. Change `DiffFactor::propagate` signature to `propagate(&mut self, vars: &mut VarStore, alpha: f64)`. Dispatch into `propagate_with_alpha`.
5. Replace `run_chain`'s hardcoded `1e-6` / `10` / undamped with `self.convergence.{epsilon, max_iter, alpha}`. Pass alpha to all three `lf.propagate` callsites. Add `debug_assert!` for alpha range.
There are ~20 test callsites to update. Most pass `&mut ScratchArena::new()` as the last argument; the new convergence parameter slots in just before that.
**Files:**
- Modify: `src/game.rs` (struct fields, two constructors, two `_with_arena` entry points, `DiffFactor::propagate`, `run_chain`, ~20 test callsites)
- [ ] **Step 1: Add the `convergence` field to `OwnedGame` and `Game`**
In `src/game.rs`, modify the `OwnedGame<T, D>` struct (currently `src/game.rs:83-92`) to add the field. The struct should become:
```rust
#[derive(Debug)]
#[allow(dead_code)]
pub struct OwnedGame<T: Time, D: Drift<T>> {
teams: Vec<Vec<Rating<T, D>>>,
result: Vec<f64>,
weights: Vec<Vec<f64>>,
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64,
}
```
In the same file, modify the `Game<'a, T, D>` struct (currently `src/game.rs:148-156`) to add the field:
```rust
#[derive(Debug)]
pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
teams: Vec<Vec<Rating<T, D>>>,
result: &'a [f64],
weights: &'a [Vec<f64>],
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64,
}
```
(Code won't compile yet — the constructors don't yet supply the field. Fix in Step 2.)
- [ ] **Step 2: Update `OwnedGame::new` and `OwnedGame::new_scored` to accept and forward convergence**
Replace the existing `OwnedGame::new` (currently `src/game.rs:95-113`) with:
```rust
pub(crate) fn new(
teams: Vec<Vec<Rating<T, D>>>,
result: Vec<f64>,
weights: Vec<Vec<f64>>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
) -> Self {
let mut arena = ScratchArena::new();
let g = Game::ranked_with_arena(
teams.clone(),
&result,
&weights,
p_draw,
convergence,
&mut arena,
);
let likelihoods = g.likelihoods;
let evidence = g.evidence;
Self {
teams,
result,
weights,
p_draw,
convergence,
likelihoods,
evidence,
}
}
```
Replace `OwnedGame::new_scored` (currently `src/game.rs:115-133`) with:
```rust
pub(crate) fn new_scored(
teams: Vec<Vec<Rating<T, D>>>,
scores: Vec<f64>,
weights: Vec<Vec<f64>>,
score_sigma: f64,
convergence: crate::ConvergenceOptions,
) -> Self {
let mut arena = ScratchArena::new();
let g = Game::scored_with_arena(
teams.clone(),
&scores,
&weights,
score_sigma,
convergence,
&mut arena,
);
let likelihoods = g.likelihoods;
let evidence = g.evidence;
Self {
teams,
result: scores,
weights,
p_draw: 0.0,
convergence,
likelihoods,
evidence,
}
}
```
- [ ] **Step 3: Update `Game::ranked` and `Game::scored` to forward `options.convergence`**
In `src/game.rs:402-433`, replace the body's final `Ok(...)` line in `Game::ranked` (currently `Ok(OwnedGame::new(teams_owned, result, weights, options.p_draw))`) with:
```rust
Ok(OwnedGame::new(
teams_owned,
result,
weights,
options.p_draw,
options.convergence,
))
```
In `src/game.rs:435-469`, replace the final `Ok(OwnedGame::new_scored(...))` in `Game::scored` with:
```rust
Ok(OwnedGame::new_scored(
teams_owned,
scores,
weights,
options.score_sigma,
options.convergence,
))
```
- [ ] **Step 4: Update `Game::ranked_with_arena` and `Game::scored_with_arena` to accept convergence**
Replace `Game::ranked_with_arena` (currently `src/game.rs:159-201`) signature and body to add the parameter and store it on `self`:
```rust
pub(crate) fn ranked_with_arena(
teams: Vec<Vec<Rating<T, D>>>,
result: &'a [f64],
weights: &'a [Vec<f64>],
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) -> Self {
debug_assert!(
result.len() == teams.len(),
"result must have the same length as teams"
);
debug_assert!(
weights
.iter()
.zip(teams.iter())
.all(|(w, t)| w.len() == t.len()),
"weights must have the same dimensions as teams"
);
debug_assert!(
(0.0..1.0).contains(&p_draw),
"draw probability must be >= 0.0 and < 1.0"
);
debug_assert!(
p_draw > 0.0 || {
let mut r = result.to_vec();
r.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
r.windows(2).all(|w| w[0] != w[1])
},
"draw must be > 0.0 if there are teams with draw"
);
debug_assert!(
convergence.alpha > 0.0 && convergence.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0]"
);
let mut this = Self {
teams,
result,
weights,
p_draw,
convergence,
likelihoods: Vec::new(),
evidence: 0.0,
};
this.likelihoods(arena);
this
}
```
Replace `Game::scored_with_arena` (currently `src/game.rs:203-234`) similarly:
```rust
pub(crate) fn scored_with_arena(
teams: Vec<Vec<Rating<T, D>>>,
scores: &'a [f64],
weights: &'a [Vec<f64>],
score_sigma: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) -> Self {
debug_assert!(
scores.len() == teams.len(),
"scores must have the same length as teams"
);
debug_assert!(
weights
.iter()
.zip(teams.iter())
.all(|(w, t)| w.len() == t.len()),
"weights must have the same dimensions as teams"
);
debug_assert!(score_sigma > 0.0, "score_sigma must be positive");
debug_assert!(
convergence.alpha > 0.0 && convergence.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0]"
);
let mut this = Self {
teams,
result: scores,
weights,
p_draw: 0.0,
convergence,
likelihoods: Vec::new(),
evidence: 0.0,
};
this.likelihoods_scored(arena, score_sigma);
this
}
```
- [ ] **Step 5: Change `DiffFactor::propagate` signature to take `alpha`**
In `src/game.rs`, replace the existing `impl DiffFactor` block (currently `src/game.rs:25-54`) with:
```rust
impl DiffFactor {
pub(crate) fn diff(&self) -> VarId {
match self {
Self::Trunc(f) => f.diff,
Self::Margin(f) => f.diff,
}
}
pub(crate) fn msg(&self) -> Gaussian {
match self {
Self::Trunc(f) => f.msg,
Self::Margin(f) => f.msg,
}
}
pub(crate) fn evidence(&self) -> f64 {
match self {
Self::Trunc(f) => f.evidence_cached.unwrap_or(1.0),
Self::Margin(f) => f.evidence_cached.unwrap_or(1.0),
}
}
pub(crate) fn propagate(
&mut self,
vars: &mut crate::factor::VarStore,
alpha: f64,
) -> (f64, f64) {
match self {
Self::Trunc(f) => f.propagate_with_alpha(vars, alpha),
Self::Margin(f) => f.propagate_with_alpha(vars, alpha),
}
}
}
```
(Note the `use crate::factor::Factor;` line in the old impl is no longer needed — it was there to bring the trait method into scope. The new code calls the inherent `propagate_with_alpha` directly. Remove the import if it's now unused.)
- [ ] **Step 6: Update `run_chain` to read `self.convergence` and pass `alpha` to propagate**
In `src/game.rs`, modify `run_chain` (currently starts at `src/game.rs:236`). Three site changes:
(a) At the top of `run_chain`'s body (immediately after `arena.reset();`), add:
```rust
let alpha = self.convergence.alpha;
let epsilon = self.convergence.epsilon;
let max_iter = self.convergence.max_iter;
```
(b) Replace the loop guard `while tuple_gt(step, 1e-6) && iter < 10 {` with:
```rust
while tuple_gt(step, epsilon) && iter < max_iter {
```
(c) Replace each of the three `lf.propagate(&mut arena.vars)` callsites:
- In the forward sweep: `let d = lf.propagate(&mut arena.vars, alpha);`
- In the backward sweep: `let d = lf.propagate(&mut arena.vars, alpha);`
- In the `n_diffs == 1` special case: `links[0].propagate(&mut arena.vars, alpha);`
- [ ] **Step 7: Update all test callsites of `ranked_with_arena` and `scored_with_arena`**
Run `cargo build` to surface compile errors. Each error will be at a callsite of `Game::ranked_with_arena(...)` or `Game::scored_with_arena(...)` missing the new `convergence` parameter. The fix at every site: add `crate::ConvergenceOptions::default(),` as the second-to-last argument (immediately before `&mut ScratchArena::new()` or `&mut arena`).
Example transformation. Before:
```rust
let g = Game::ranked_with_arena(
teams,
&result,
&weights,
p_draw,
&mut arena,
);
```
After:
```rust
let g = Game::ranked_with_arena(
teams,
&result,
&weights,
p_draw,
crate::ConvergenceOptions::default(),
&mut arena,
);
```
Apply the same transformation to all 16+ `ranked_with_arena` callsites and the 2 `scored_with_arena` callsites in `src/game.rs`. They are all in `#[cfg(test)] mod` sections at the bottom of the file. After every callsite is updated, `cargo build` must succeed.
If a test uses inline `&mut ScratchArena::new()` rather than a `let mut arena = ...` binding, the same insertion applies — `crate::ConvergenceOptions::default(),` slots in immediately before the arena argument.
- [ ] **Step 8: Run the full test suite — bit-equal regression net**
Run: `cargo test --lib`
Expected: every existing test passes unchanged. Total count = previous baseline + 0 (no new tests added in this task). If ANY existing assertion fails, the integration leaked damping into the undamped (α=1.0) trajectory — debug before proceeding.
- [ ] **Step 9: Run integration tests too**
Run: `cargo test`
Expected: all integration tests pass.
- [ ] **Step 10: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 11: Commit**
```bash
git add src/game.rs
git commit -m "$(cat <<'EOF'
feat(game): plumb ConvergenceOptions through to run_chain
Game and OwnedGame gain a convergence: ConvergenceOptions field set at
construction. Game::{ranked,scored} forward options.convergence into
OwnedGame::{new,new_scored} (previously dropped on the floor).
{ranked,scored}_with_arena take it as a parameter. run_chain reads
self.convergence.{epsilon, max_iter, alpha} instead of hardcoded
1e-6 / 10 / undamped. DiffFactor::propagate gains an alpha parameter
and dispatches into Trunc/MarginFactor::propagate_with_alpha.
Existing test callsites updated to pass ConvergenceOptions::default().
Default alpha is 1.0, so all 88 lib + 27 integration test goldens
remain bit-equal.
EOF
)"
```
---
### Task 6: End-to-end integration tests for `alpha` and `max_iter`
**Files:**
- Modify: `src/game.rs` (add tests at the end of an existing `#[cfg(test)] mod tests` block, OR a new `mod convergence_options_tests` block at the very end of the file — pick whichever existing block currently holds the most-recent game-level tests)
This task adds the integration coverage that proves `run_chain` actually reads `ConvergenceOptions`. Two assertions:
1. `max_iter = 1` on a graph that needs more iterations produces a measurably different posterior than `max_iter = 30`.
2. `alpha = 0.5` on the same graph reaches the same converged posterior as `alpha = 1.0` (just slower) — proves damping doesn't break correctness on convergent graphs.
- [ ] **Step 1: Write the failing tests**
Add to the existing `#[cfg(test)] mod tests` block in `src/game.rs` (the one near the bottom containing the other game-level tests):
```rust
#[test]
fn run_chain_honours_max_iter_in_convergence_options() {
use crate::{ConvergenceOptions, drift::ConstantDrift, rating::Rating};
let players: Vec<Rating<i64, ConstantDrift>> = (0..4)
.map(|_| Rating::default())
.collect();
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
let result = vec![3.0, 2.0, 1.0, 0.0];
let weights = vec![vec![1.0]; 4];
// Capped at 1 iteration: cannot fully propagate down a 4-team chain.
let mut arena = ScratchArena::new();
let g_capped = Game::ranked_with_arena(
teams.clone(),
&result,
&weights,
0.0,
ConvergenceOptions {
max_iter: 1,
..ConvergenceOptions::default()
},
&mut arena,
);
let posteriors_capped = g_capped.posteriors();
// Same inputs, plenty of iterations: fully converged.
let mut arena = ScratchArena::new();
let g_full = Game::ranked_with_arena(
teams,
&result,
&weights,
0.0,
ConvergenceOptions::default(),
&mut arena,
);
let posteriors_full = g_full.posteriors();
// The two posteriors should differ — capped did not converge.
let mut max_diff: f64 = 0.0;
for (team_capped, team_full) in posteriors_capped.iter().zip(posteriors_full.iter()) {
for (g_capped, g_full) in team_capped.iter().zip(team_full.iter()) {
max_diff = max_diff.max((g_capped.mu() - g_full.mu()).abs());
max_diff = max_diff.max((g_capped.sigma() - g_full.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"max_iter=1 should differ from full convergence; max_diff={max_diff}"
);
}
#[test]
fn run_chain_with_damping_converges_to_same_posterior() {
use crate::{ConvergenceOptions, drift::ConstantDrift, rating::Rating};
let players: Vec<Rating<i64, ConstantDrift>> = (0..4)
.map(|_| Rating::default())
.collect();
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
let result = vec![3.0, 2.0, 1.0, 0.0];
let weights = vec![vec![1.0]; 4];
let mut arena = ScratchArena::new();
let g_undamped = Game::ranked_with_arena(
teams.clone(),
&result,
&weights,
0.0,
ConvergenceOptions::default(),
&mut arena,
);
let posteriors_undamped = g_undamped.posteriors();
// alpha=0.5 with extra iterations: should reach the same fixed point.
let mut arena = ScratchArena::new();
let g_damped = Game::ranked_with_arena(
teams,
&result,
&weights,
0.0,
ConvergenceOptions {
alpha: 0.5,
max_iter: 100,
..ConvergenceOptions::default()
},
&mut arena,
);
let posteriors_damped = g_damped.posteriors();
let mut max_diff: f64 = 0.0;
for (team_u, team_d) in posteriors_undamped.iter().zip(posteriors_damped.iter()) {
for (g_u, g_d) in team_u.iter().zip(team_d.iter()) {
max_diff = max_diff.max((g_u.mu() - g_d.mu()).abs());
max_diff = max_diff.max((g_u.sigma() - g_d.sigma()).abs());
}
}
assert!(
max_diff < 1e-4,
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
);
}
```
- [ ] **Step 2: Run the new tests to verify they pass**
Run: `cargo test --lib run_chain_honours_max_iter_in_convergence_options run_chain_with_damping_converges_to_same_posterior`
Expected: 2 passed.
If the `max_iter=1` test fails because the 4-team chain happens to converge in 1 iteration with default starting state, replace `max_iter: 1` with `max_iter: 0` and adjust the test name accordingly — `max_iter=0` will skip the loop entirely and return uniform-prior likelihoods that obviously differ from converged.
If the `alpha=0.5` convergence test fails (max_diff is too large), increase `max_iter: 100` to `max_iter: 200` — heavier damping needs more iterations.
- [ ] **Step 3: Run the full library test suite**
Run: `cargo test --lib`
Expected: all passing, count = previous baseline + 2.
- [ ] **Step 4: Run integration tests**
Run: `cargo test`
Expected: all passing.
- [ ] **Step 5: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 6: Commit**
```bash
git add src/game.rs
git commit -m "$(cat <<'EOF'
test(game): integration tests for ConvergenceOptions behavior
Two end-to-end tests on a 4-team ranked game:
- max_iter=1 produces measurably different posteriors than the default,
proving run_chain reads convergence.max_iter
- alpha=0.5 with extra iterations reaches the same fixed point as
alpha=1.0, proving damping doesn't break convergence on benign graphs
EOF
)"
```
---
## Self-review (writer's note)
**Spec coverage:**
- Spec § "What ships" item 1 (`alpha` field) → Task 2 ✓
- Spec § "What ships" item 2 (`run_chain` reads ConvergenceOptions) → Task 5 (steps 6, 7) ✓
- Spec § "What ships" item 3 (`Gaussian::damp_natural`) → Task 1 ✓
- Spec § "What ships" item 4 (`TruncFactor::propagate_with_alpha` + delegate) → Task 3 ✓
- Spec § "What ships" item 4 (`MarginFactor::propagate_with_alpha` + delegate) → Task 4 ✓
- Spec § "What ships" item 5 (`DiffFactor::propagate` gains `alpha`) → Task 5 step 5 ✓
- Spec § "Convergence semantics" (delta of damped msg, not raw) → enforced in Task 3 step 3 and Task 4 step 3 (`old_msg.delta(damped)`)
- Spec § "Testing strategy" §1 (regression net) → Task 5 step 8, Task 6 step 3
- Spec § "Testing strategy" §2 (damp_natural) → Task 1 ✓
- Spec § "Testing strategy" §3 (TruncFactor damping shrinks step) → Task 3 ✓
- Spec § "Testing strategy" §4 (MarginFactor parity) → Task 4 ✓
- Spec § "Testing strategy" §5 (run_chain honours max_iter) → Task 6 ✓
- Spec § "Testing strategy" §6 (default alpha = 1.0) → Task 2 ✓
**Out-of-scope items correctly absent:** No Schedule trait integration, no nat-param convergence switch, no oscillation auto-detect, no Residual / OneShot / SynergyFactor / ScoreFactor work.
**Type / signature consistency:**
- `Gaussian::damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian` — same signature in Task 1 (definition) and Tasks 3/4 (call sites) ✓
- `propagate_with_alpha(&mut self, vars: &mut VarStore, alpha: f64) -> (f64, f64)` — consistent in Tasks 3 and 4 ✓
- `DiffFactor::propagate(&mut self, vars: &mut crate::factor::VarStore, alpha: f64) -> (f64, f64)` — Task 5 step 5; matches `lf.propagate(&mut arena.vars, alpha)` in step 6 ✓
- `OwnedGame::new(..., convergence: ConvergenceOptions)` — Task 5 step 2; matches `Game::ranked` forwarding `options.convergence` in step 3 ✓
- `ranked_with_arena(..., convergence: crate::ConvergenceOptions, arena: &mut ScratchArena)` — Task 5 step 4; matches the expected callsite shape in step 7 ✓
**No placeholders detected.**
**Note on Task 5 size:** This task is unusually large (11 steps) because the field addition, struct/constructor changes, signature changes, and test callsite updates must land atomically — splitting them produces non-compiling intermediate states. The 11 steps are organised so the final `cargo build` only succeeds after Step 7 completes.