# Damped EP — Game-Local Damping ## Summary Add an opt-in EP damping knob to within-game inference. Users set `ConvergenceOptions::alpha < 1.0` to damp message updates and stabilise oscillating fixed-point loops on hard graphs. `alpha = 1.0` (the default) is bit-equal to today. This is the smallest-scope realisation of the spec's `Damped` schedule: **game-local**, not plumbed through the `Schedule` trait. The `Schedule` trait is shipped infrastructure that `run_chain` does not currently call; wiring `Schedule` into game inference is a separate future task. This design touches only what the user can actually reach via `GameOptions`. ## Scope ### What ships 1. New field `ConvergenceOptions::alpha: f64` (default `1.0`). 2. `run_chain` reads `options.convergence.{epsilon, max_iter, alpha}` instead of the hardcoded `1e-6` / `10` / undamped — fixes the existing latent bug where the first two were already on `GameOptions` but never read by inference. 3. `Gaussian::damp_natural(self, new, alpha) -> Gaussian` — public helper computing `α·new + (1−α)·self` in natural-parameter space. 4. `TruncFactor` and `MarginFactor` gain inherent `propagate_with_alpha(&mut self, vars, alpha) -> (f64, f64)`. Their `Factor::propagate` impls become one-line delegations passing `alpha = 1.0`. 5. `DiffFactor::propagate` (game-private enum at `src/game.rs:20-54`) gains an `alpha: f64` parameter and dispatches into the underlying factor's `propagate_with_alpha`. ### What does not ship - No `Damped` impl in `src/schedule.rs`. The `Schedule` trait stays as it is; integration with `run_chain` is a separate task. - No nat-param convergence switch. `(|Δmu|, |Δsigma|)` stays the delta basis (matches today). The spec's "stopping in natural-param space" wants its own design pass and test re-tuning. - No oscillation auto-detect. `alpha` is user-supplied and constant for the duration of a `run_chain` call. - No `Residual`, `OneShot`, or `SynergyFactor` / `ScoreFactor` work — separate future plans. ## Design ### `ConvergenceOptions::alpha` ```rust // src/convergence.rs #[derive(Clone, Copy, Debug)] pub struct ConvergenceOptions { pub max_iter: usize, pub epsilon: f64, pub alpha: f64, } impl Default for ConvergenceOptions { fn default() -> Self { Self { max_iter: crate::ITERATIONS, epsilon: crate::EPSILON, alpha: 1.0, } } } ``` `alpha = 1.0` ⇒ undamped (bit-equal to today). Recommended starting point if a graph oscillates: `0.5`–`0.7`. Values approaching `0.0` make each step tinier and slow convergence; `alpha = 0.0` is degenerate (factor never updates). Validation in `run_chain`: ```rust debug_assert!( opts.convergence.alpha > 0.0 && opts.convergence.alpha <= 1.0, "convergence alpha must be in (0.0, 1.0]" ); ``` ### `Gaussian::damp_natural` ```rust impl Gaussian { /// EP damping in natural-parameter space: `α·new + (1−α)·self`. /// /// Used by within-game schedules 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(), ) } } ``` Public on `Gaussian`. The name encodes the WHY (EP damping); the doc comment fixes the math. No new dependency. The existing `Mul for Gaussian` is **distribution scaling** (`sigma → sigma·|scalar|`), not nat-param interpolation, so it can't be reused here. ### `TruncFactor::propagate_with_alpha` ```rust impl TruncFactor { 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 (NOT cavity * new_msg with damping) 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) } } ``` Two important points: - The variable receives `cavity * damped` (i.e. `cavity * self.msg`), not `trunc`. With `alpha = 1.0` these are equal (since `cavity * new_msg = trunc` by construction), so today's behaviour is preserved bit-equal. With `alpha < 1.0` the marginal reflects the partially-applied update. - The reported delta is `old_msg.delta(damped)` — delta of the actually stored message, not of the raw `new_msg`. This is the textbook EP damping convention: the convergence loop measures the trajectory it is actually walking. `MarginFactor` follows the same shape, with its own `propagate_with_alpha` body (the existing `propagate` math, with the `damp_natural` step inserted in the same place and the var write switched to `cavity * damped`). ### `DiffFactor::propagate` signature ```rust // src/game.rs impl DiffFactor { pub(crate) fn propagate( &mut self, vars: &mut 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), } } } ``` `DiffFactor` is `pub(crate)` and only used inside `run_chain`, so the signature change has no public-API impact. ### `run_chain` changes Inside `Game::run_chain` (`src/game.rs:236-348`): 1. Capture `let alpha = opts.convergence.alpha;` once at the top (avoids repeated `opts.convergence.alpha` lookups in the hot loop). 2. Replace the loop guard `while tuple_gt(step, 1e-6) && iter < 10` with `while tuple_gt(step, opts.convergence.epsilon) && iter < opts.convergence.max_iter`. 3. Replace each `lf.propagate(&mut arena.vars)` call site (three of them: forward sweep, backward sweep, `n_diffs == 1` special case) with `lf.propagate(&mut arena.vars, alpha)`. The threading of `opts: &GameOptions` into `run_chain` is the only new caller obligation. Today `run_chain` doesn't take `opts`; the two callers (`likelihoods`, `likelihoods_scored`) currently invoke it without options. Both will need to pass the options through. The `Game<'a, T, D>` struct does not currently hold `GameOptions`; the options are constructed and discarded around the call to `{ranked,scored}_with_arena`. So: - `Game::ranked_with_arena` and `Game::scored_with_arena` already receive `p_draw` / `score_sigma` as scalar params; we extend them to accept `&ConvergenceOptions` (or the full `&GameOptions`) too. - `likelihoods` / `likelihoods_scored` either store the options on `Game` or accept them as method parameters and forward to `run_chain`. The simplest plumbing: store `convergence: ConvergenceOptions` as a field on `Game<'a, T, D>` and `OwnedGame` populated at construction time. Then `run_chain` can read it from `&self`. ## Convergence semantics With `alpha < 1.0` the per-step update shrinks; convergence may take more iterations to reach the same `epsilon` threshold. Users who damp should also raise `max_iter` accordingly. Documentation example: ```rust let mut opts = GameOptions::default(); opts.convergence.alpha = 0.5; opts.convergence.max_iter = 30; ``` ## Testing strategy ### Regression net (no new file) The existing 88 lib tests and 27 integration tests are the bit-equal regression net. With `alpha = 1.0` (the default), every assertion must pass unchanged. If any test fails, the damping path leaked into the undamped trajectory. ### New tests 1. **`Gaussian::damp_natural` arithmetic** (`src/gaussian.rs` test mod): - `α = 1.0` returns `new` exactly (bit-equal `pi` and `tau`). - `α = 0.0` returns `self` exactly. - `α = 0.5`: pi and tau are exact midpoints in nat-param space. - Three asserts, no new file. 2. **`TruncFactor::propagate_with_alpha` shrinks the step** (`src/factor/trunc.rs` test mod): - Set up a TruncFactor step. Run `propagate_with_alpha(α=1.0)` once, record `delta_undamped` and the resulting `self.msg`. - Reset to a fresh factor at the same starting state. Run `propagate_with_alpha(α=0.5)` once, record `delta_damped` and `damped_msg`. - Assert: `damped_msg.pi()` equals `0.5 * undamped_msg.pi() + 0.5 * initial_msg.pi()` within 1e-12 (and same for `tau`). - Assert: `delta_damped.0 <= delta_undamped.0` (mu-delta is no larger; the relationship is monotone in `α` but not strictly `0.5×` for the `delta()` function which is `(|Δmu|, |Δsigma|)`). 3. **`MarginFactor::propagate_with_alpha` parity** (`src/factor/margin.rs` test mod): - Same shape as #2, on a `MarginFactor` step. 4. **`run_chain` honours `ConvergenceOptions::max_iter`** (in an existing or new game-level test): - Construct a 4-team ranked game that normally converges in ~5 iterations. - Set `opts.convergence.max_iter = 1`. Assert the per-iteration `step` returned (or observable indirectly via posterior delta vs. the converged answer) is non-zero — i.e. the loop stopped early. - Set `opts.convergence.max_iter = 30`. Assert posteriors match the baseline within `epsilon`. 5. **Damping default is `1.0` and produces bit-equal output** (smoke test, can be a single assertion in an existing test): - `assert_eq!(ConvergenceOptions::default().alpha, 1.0);` - Existing goldens prove the bit-equality. No oscillation-stabilisation test (would require constructing a pathological graph specifically to oscillate; out of scope for a minimal ship). ## Verification gates Per task: ```bash cargo +nightly fmt cargo clippy --all-targets -- -D warnings cargo test --lib cargo test ``` All must succeed. Test count grows by exactly the new tests above (roughly +5–8 lib tests). ## Risks - **Marginal-update change is subtle.** Switching the variable write from `trunc` to `cavity * damped` is intentionally a no-op when `alpha = 1.0` (since `cavity * new_msg = trunc`), but it changes the arithmetic path. If `Gaussian` arithmetic has any non-associativity in floating-point that the old form happened to dodge, goldens could shift by 1 ULP. Mitigation: TDD — write the regression test (run all existing tests with `alpha = 1.0`) **first**, before changing the variable-write line. - **`run_chain` signature change ripples to two callers.** Trivial but must be done atomically with the field addition on `Game` / `OwnedGame`. - **`alpha` validation only in debug builds.** A release build will silently accept `alpha = 0.0` or `alpha > 1.0` and produce nonsense. This matches the existing pattern (`debug_assert!` for input validation in `Game::ranked_with_arena`); upgrading to `Result` is out of scope. ## Out-of-scope follow-ups (logged for future plans) - Wire `Schedule` into `run_chain` (so `Damped` lands as a real `Schedule` impl alongside `EpsilonOrMax`). - Switch convergence check to `(|Δpi|, |Δtau|)` per spec §"Stopping in natural-param space". - Oscillation auto-detect (engage `alpha < 1.0` only after N non-monotone steps). - `Residual` schedule (priority queue). - `SynergyFactor`, `ScoreFactor` (new EP factor types).