From 48a6049dc6029e743e6a121d1bf5601c3dfb9f91 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 8 May 2026 14:52:36 +0200 Subject: [PATCH] docs: spec for game-local Damped EP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smallest-scope realisation of spec §"Built-in schedules" Damped: a ConvergenceOptions::alpha field plumbed through run_chain to a new Gaussian::damp_natural helper applied inside TruncFactor and MarginFactor's propagate. alpha=1.0 default keeps every existing golden bit-equal; alpha<1.0 stabilises oscillating fixed-point loops on hard graphs. Defers Schedule trait integration, nat-param convergence switch, oscillation auto-detect, Residual/OneShot, and Synergy/ScoreFactor — each gets its own future plan. --- .../2026-05-08-damped-schedule-design.md | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-damped-schedule-design.md diff --git a/docs/superpowers/specs/2026-05-08-damped-schedule-design.md b/docs/superpowers/specs/2026-05-08-damped-schedule-design.md new file mode 100644 index 0000000..27d65e6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-damped-schedule-design.md @@ -0,0 +1,320 @@ +# 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).