43cc6d82f9
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.
1089 lines
38 KiB
Markdown
1089 lines
38 KiB
Markdown
# 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.
|