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.
12 KiB
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
- New field
ConvergenceOptions::alpha: f64(default1.0). run_chainreadsoptions.convergence.{epsilon, max_iter, alpha}instead of the hardcoded1e-6/10/ undamped — fixes the existing latent bug where the first two were already onGameOptionsbut never read by inference.Gaussian::damp_natural(self, new, alpha) -> Gaussian— public helper computingα·new + (1−α)·selfin natural-parameter space.TruncFactorandMarginFactorgain inherentpropagate_with_alpha(&mut self, vars, alpha) -> (f64, f64). TheirFactor::propagateimpls become one-line delegations passingalpha = 1.0.DiffFactor::propagate(game-private enum atsrc/game.rs:20-54) gains analpha: f64parameter and dispatches into the underlying factor'spropagate_with_alpha.
What does not ship
- No
Dampedimpl insrc/schedule.rs. TheScheduletrait stays as it is; integration withrun_chainis 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.
alphais user-supplied and constant for the duration of arun_chaincall. - No
Residual,OneShot, orSynergyFactor/ScoreFactorwork — separate future plans.
Design
ConvergenceOptions::alpha
// 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:
debug_assert!(
opts.convergence.alpha > 0.0 && opts.convergence.alpha <= 1.0,
"convergence alpha must be in (0.0, 1.0]"
);
Gaussian::damp_natural
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<f64> for Gaussian is distribution scaling
(sigma → sigma·|scalar|), not nat-param interpolation, so it can't be
reused here.
TruncFactor::propagate_with_alpha
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), nottrunc. Withalpha = 1.0these are equal (sincecavity * new_msg = truncby construction), so today's behaviour is preserved bit-equal. Withalpha < 1.0the marginal reflects the partially-applied update. - The reported delta is
old_msg.delta(damped)— delta of the actually stored message, not of the rawnew_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
// 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):
- Capture
let alpha = opts.convergence.alpha;once at the top (avoids repeatedopts.convergence.alphalookups in the hot loop). - Replace the loop guard
while tuple_gt(step, 1e-6) && iter < 10withwhile tuple_gt(step, opts.convergence.epsilon) && iter < opts.convergence.max_iter. - Replace each
lf.propagate(&mut arena.vars)call site (three of them: forward sweep, backward sweep,n_diffs == 1special case) withlf.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_arenaandGame::scored_with_arenaalready receivep_draw/score_sigmaas scalar params; we extend them to accept&ConvergenceOptions(or the full&GameOptions) too.likelihoods/likelihoods_scoredeither store the options onGameor accept them as method parameters and forward torun_chain.
The simplest plumbing: store convergence: ConvergenceOptions as a
field on Game<'a, T, D> and OwnedGame<T, D> 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:
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
-
Gaussian::damp_naturalarithmetic (src/gaussian.rstest mod):α = 1.0returnsnewexactly (bit-equalpiandtau).α = 0.0returnsselfexactly.α = 0.5: pi and tau are exact midpoints in nat-param space.- Three asserts, no new file.
-
TruncFactor::propagate_with_alphashrinks the step (src/factor/trunc.rstest mod):- Set up a TruncFactor step. Run
propagate_with_alpha(α=1.0)once, recorddelta_undampedand the resultingself.msg. - Reset to a fresh factor at the same starting state. Run
propagate_with_alpha(α=0.5)once, recorddelta_dampedanddamped_msg. - Assert:
damped_msg.pi()equals0.5 * undamped_msg.pi() + 0.5 * initial_msg.pi()within 1e-12 (and same fortau). - Assert:
delta_damped.0 <= delta_undamped.0(mu-delta is no larger; the relationship is monotone inαbut not strictly0.5×for thedelta()function which is(|Δmu|, |Δsigma|)).
- Set up a TruncFactor step. Run
-
MarginFactor::propagate_with_alphaparity (src/factor/margin.rstest mod):- Same shape as #2, on a
MarginFactorstep.
- Same shape as #2, on a
-
run_chainhonoursConvergenceOptions::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-iterationstepreturned (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 withinepsilon.
-
Damping default is
1.0and 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:
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
trunctocavity * dampedis intentionally a no-op whenalpha = 1.0(sincecavity * new_msg = trunc), but it changes the arithmetic path. IfGaussianarithmetic 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 withalpha = 1.0) first, before changing the variable-write line. run_chainsignature change ripples to two callers. Trivial but must be done atomically with the field addition onGame/OwnedGame.alphavalidation only in debug builds. A release build will silently acceptalpha = 0.0oralpha > 1.0and produce nonsense. This matches the existing pattern (debug_assert!for input validation inGame::ranked_with_arena); upgrading toResultis out of scope.
Out-of-scope follow-ups (logged for future plans)
- Wire
Scheduleintorun_chain(soDampedlands as a realScheduleimpl alongsideEpsilonOrMax). - Switch convergence check to
(|Δpi|, |Δtau|)per spec §"Stopping in natural-param space". - Oscillation auto-detect (engage
alpha < 1.0only after N non-monotone steps). Residualschedule (priority queue).SynergyFactor,ScoreFactor(new EP factor types).