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.
38 KiB
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
Gaussianstores natural parameterspi = 1/sigma²andtau = mu/sigma². The accessors arepi(),tau(). ConstructorGaussian::from_natural(pi, tau)ispub(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::rundepends on it.DiffFactor(src/game.rs:20-54) ispub(crate). Its current signature ispropagate(&mut self, vars: &mut VarStore) -> (f64, f64). Addingalpha: f64here is fine — onlyrun_chaincalls it.ConvergenceOptionslives atsrc/convergence.rs. It isCopy + Clone + Debug. Keep itCopy.run_chainbody (src/game.rs:236-348) currently hardcodestuple_gt(step, 1e-6) && iter < 10. Three call sites uself.propagate(&mut arena.vars)— forward sweep, backward sweep, and then_diffs == 1special case.Game<'a, T, D>(src/game.rs:148-156) andOwnedGame<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. Theconvergencefield onGameOptionsis silently dropped today byOwnedGame::new/OwnedGame::new_scored— fixing this is part of the work.- All ~20
ranked_with_arena/scored_with_arenacallsites are in tests insidesrc/game.rs. None are intests/integration files.
Task 1: Add Gaussian::damp_natural helper
Files:
-
Modify:
src/gaussian.rs(add method to existingimpl Gaussianblock + 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:
#[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 }:
/// 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
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):
#[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:
#[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
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:
#[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_alphaand 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:
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
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:
#[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:
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
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:
- Add
convergence: ConvergenceOptionsfield to bothOwnedGame<T, D>andGame<'a, T, D>. - Extend
OwnedGame::newandOwnedGame::new_scoredto acceptconvergence: ConvergenceOptions. UpdateGame::rankedandGame::scoredto forwardoptions.convergence. - Extend
Game::ranked_with_arenaandGame::scored_with_arenato acceptconvergence: ConvergenceOptions. Store onself. - Change
DiffFactor::propagatesignature topropagate(&mut self, vars: &mut VarStore, alpha: f64). Dispatch intopropagate_with_alpha. - Replace
run_chain's hardcoded1e-6/10/ undamped withself.convergence.{epsilon, max_iter, alpha}. Pass alpha to all threelf.propagatecallsites. Adddebug_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_arenaentry points,DiffFactor::propagate,run_chain, ~20 test callsites) -
Step 1: Add the
convergencefield toOwnedGameandGame
In src/game.rs, modify the OwnedGame<T, D> struct (currently src/game.rs:83-92) to add the field. The struct should become:
#[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:
#[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::newandOwnedGame::new_scoredto accept and forward convergence
Replace the existing OwnedGame::new (currently src/game.rs:95-113) with:
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:
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::rankedandGame::scoredto forwardoptions.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:
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:
Ok(OwnedGame::new_scored(
teams_owned,
scores,
weights,
options.score_sigma,
options.convergence,
))
- Step 4: Update
Game::ranked_with_arenaandGame::scored_with_arenato 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:
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:
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::propagatesignature to takealpha
In src/game.rs, replace the existing impl DiffFactor block (currently src/game.rs:25-54) with:
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_chainto readself.convergenceand passalphato 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:
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:
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 == 1special case:links[0].propagate(&mut arena.vars, alpha); -
Step 7: Update all test callsites of
ranked_with_arenaandscored_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:
let g = Game::ranked_with_arena(
teams,
&result,
&weights,
p_draw,
&mut arena,
);
After:
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
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 testsblock, OR a newmod convergence_options_testsblock 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:
max_iter = 1on a graph that needs more iterations produces a measurably different posterior thanmax_iter = 30.alpha = 0.5on the same graph reaches the same converged posterior asalpha = 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):
#[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
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 (
alphafield) → Task 2 ✓ - Spec § "What ships" item 2 (
run_chainreads 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::propagategainsalpha) → 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; matcheslf.propagate(&mut arena.vars, alpha)in step 6 ✓OwnedGame::new(..., convergence: ConvergenceOptions)— Task 5 step 2; matchesGame::rankedforwardingoptions.convergencein 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.