14 Commits

Author SHA1 Message Date
logaritmisk dbce69f350 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
2026-05-08 15:13:23 +02:00
logaritmisk 0705986929 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.

In-tree callsites in src/time_slice.rs and src/history.rs pass
ConvergenceOptions::default(). Pre-existing T2 fallout in tests,
benches, and the atp example (struct literals missing the new alpha
field) is fixed by adding alpha: 1.0 so the workspace builds clean.
Default alpha is 1.0, so all 96 lib + 27 integration test goldens
remain bit-equal.
2026-05-08 15:10:35 +02:00
logaritmisk aacaa60baa 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.
2026-05-08 15:03:45 +02:00
logaritmisk fcfe0ffe37 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.
2026-05-08 15:02:09 +02:00
logaritmisk 0fa4e7d277 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.
2026-05-08 15:00:34 +02:00
logaritmisk 0dd7dab266 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.
2026-05-08 14:59:18 +02:00
logaritmisk 43cc6d82f9 docs: implementation plan for game-local Damped EP
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.
2026-05-08 14:57:41 +02:00
logaritmisk 48a6049dc6 docs: spec for game-local Damped EP
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 14:52:36 +02:00
logaritmisk 1445c08896 docs: fix stale numerics in t4-margin-factor plan
The plan's prose quoted Z_cav ≈ 0.046827 and log_evidence ≈ -3.0613,
which diverged from the values asserted by the shipped test in
src/factor/mod.rs (-3.062235327364623). Update prose and the matching
code comment to 0.04678 / -3.0622.
2026-05-08 14:37:58 +02:00
logaritmisk f6a83e4dc6 refactor: make BuiltinFactor::log_evidence match exhaustive
Replace the `_ => 0.0` wildcard with explicit
`Self::TeamSum(_) | Self::RankDiff(_) => 0.0`. No behavioral change;
future variants now produce a compile error instead of being silently
absorbed by the wildcard.
2026-05-08 14:37:13 +02:00
logaritmisk 68b589b965 refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
Both methods were 95-line near-duplicates differing only in the closure
that builds the per-diff DiffFactor. Extract the shared body as a
private run_chain<F>(&self, arena, make_link) helper that returns
(evidence, likelihoods); the two callers shrink to ~10 lines each.

Pure code-shape change: posteriors and evidence remain bit-equal; all
existing tests (lib + integration) pass unchanged.
2026-05-08 14:36:35 +02:00
logaritmisk 7481c31ad8 docs: implementation plan for post-T4-MarginFactor tech debt cleanup
Three-task plan covering the run_chain dedup, exhaustive BuiltinFactor
log_evidence match, and stale-numerics fix in the T4 plan doc.
2026-05-08 14:28:10 +02:00
logaritmisk a69a3004b2 docs: spec for post-T4-MarginFactor tech debt cleanup
Three independent cleanups: dedupe Game::likelihoods and likelihoods_scored
via a run_chain helper taking a make_link closure, make BuiltinFactor's
log_evidence match exhaustive, and fix stale numerics in the T4 plan doc.
2026-05-08 14:24:48 +02:00
logaritmisk dbaad0e7d2 fix: release generated CHANGELOG at the wrong location 2026-04-27 09:02:38 +02:00
20 changed files with 2449 additions and 333 deletions
+7 -141
View File
@@ -2,149 +2,13 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## Unreleased — T3 concurrency ## 0.1.1 - 2026-04-27
Adds rayon-backed parallel paths per Section 6 of ### Other (unconventional)
`docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md`.
### Breaking - T0 + T1 + T2: engine redesign through new API surface (#1)
- T3: rayon-backed concurrency (opt-in) (#2)
- `Send + Sync` bounds added to public traits: `Time`, `Drift<T>`, - T4 (MarginFactor): scored outcomes via Gaussian-margin EP evidence
`Observer<T>`, `Factor`, `Schedule`. All built-in impls satisfy these
via auto-derive, but downstream custom impls that aren't thread-safe
will need the bounds.
### New
- Opt-in `rayon` cargo feature. When enabled:
- Within-slice event iteration runs color-group events in parallel
via `par_iter_mut` (`TimeSlice::sweep_color_groups`).
- `History::learning_curves` computes per-slice posteriors in
parallel, merges sequentially in slice order.
- `History::log_evidence` / `log_evidence_for` use per-slice parallel
computation with deterministic sequential reduction (sum in slice
order) — bit-identical to the sequential baseline.
- `ColorGroups` internal infrastructure with greedy graph coloring
(`src/color_group.rs`). Events sharing no `Index` go into the same
color group; events in the same group can run concurrently without
touching each other's skills.
- `tests/determinism.rs` asserts bit-identical posteriors across
`RAYON_NUM_THREADS={1, 2, 4, 8}`.
- `benches/history_converge.rs` measures end-to-end convergence on
three workload shapes.
### Performance notes
- Default build (no rayon): `Batch::iteration` 23.23 µs — no regression
vs T2.
- With `--features rayon`:
- 500 events / 100 competitors / 10 per slice: 1.0× speedup.
- 2000 events / 200 competitors / 20 per slice: 1.0× speedup.
- 5000 events in one slice / 50k competitors: **1.3× speedup.**
- The spec targeted >2× speedup on 8-core offline converge. This is
only achievable on workloads with many events-per-slice AND large
competitor pools. **Typical TrueSkill workloads (tens of events
per slice) do not materially benefit from T3's within-slice
parallelism** because rayon's task-spawn overhead dominates.
- Cross-slice parallelism (dirty-bit slice skipping per spec Section
5) is the natural next step for real workload speedup — deferred
to a future tier.
### Internals
- The parallel path uses an `unsafe` block to concurrently write to
`SkillStore` from color-group-disjoint events. Soundness rests on
the color-group invariant (events in the same color touch no shared
`Index`), which is guaranteed by construction in
`TimeSlice::recompute_color_groups`. Sequential path unchanged.
- `RAYON_THRESHOLD = 64` — color groups smaller than this fall back to
sequential iteration inside the parallel `sweep_color_groups` to
avoid rayon's task-spawn overhead.
- Thread-local `ScratchArena` per rayon worker thread.
## Unreleased — T2 new API surface
Breaking: every renamed type and the new public API land together per
`docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md`
Section 7 "T2".
### Breaking renames
- `Batch``TimeSlice`
- `Player``Rating` (and the `.player` field on `Competitor` is now `.rating`)
- `Agent``Competitor`
- `IndexMap``KeyTable`
- `History` field `.batches``.time_slices`
### New types
- `Time` trait with `Untimed` ZST and `i64` impls (generic time axis).
- `Drift<T: Time>` — generified from the old `Drift` trait.
- `Event<T, K>`, `Team<K>`, `Member<K>` — typed bulk-ingest event shape.
- `Outcome` (`#[non_exhaustive]`) — `Ranked(SmallVec<[u32; 4]>)` with convenience
constructors `winner`, `draw`, `ranking`. `Scored` lands in T4.
- `Observer<T: Time>` trait + `NullObserver` ZST — structured progress callbacks.
- `ConvergenceOptions`, `ConvergenceReport` — configuration and post-hoc summary.
- `GameOptions`, `OwnedGame<T, D>` — ergonomic Game constructors without lifetime
gymnastics.
- `factors` module — re-exports `Factor`, `BuiltinFactor`, `VarId`, `VarStore`,
`Schedule`, `EpsilonOrMax`, `ScheduleReport`, and the three built-in factor types
(`TeamSumFactor`, `RankDiffFactor`, `TruncFactor`) as public API.
### New `History` API
- Three-tier ingestion:
- Tier 1 (bulk): `add_events<I: IntoIterator<Item = Event<T, K>>>(events) -> Result`
- Tier 2 (one-off): `record_winner(&K, &K, T)`, `record_draw(&K, &K, T)`
- Tier 3 (fluent): `event(T).team([...]).weights([...]).ranking([...]).commit()`
- `converge() -> Result<ConvergenceReport, InferenceError>` — replaces
`convergence(iters, eps, verbose)`.
- `current_skill(&K)`, `learning_curve(&K)`, `learning_curves()` (now keyed on `K`).
- `log_evidence()` zero-arg, `log_evidence_for(&[&K])`.
- `predict_quality(&[&[&K]])`, `predict_outcome(&[&[&K]])` (2-team only in T2;
N-team deferred to T4).
- `intern(&Q)` / `lookup(&Q)` expose the internal `KeyTable<K>` for power users.
- `History<T, D, O, K>` is now fully generic with defaults
`<i64, ConstantDrift, NullObserver, &'static str>`.
### New `Game` API
- `Game::ranked(&[&[Rating]], Outcome, &GameOptions) -> Result<OwnedGame, _>`.
- `Game::one_v_one(&Rating, &Rating, Outcome) -> Result<(Gaussian, Gaussian), _>`.
- `Game::free_for_all(&[&Rating], Outcome, &GameOptions) -> Result<OwnedGame, _>`.
- `Game::custom(...)` minimal escape hatch for user-defined factor graphs
(`#[doc(hidden)]` — full ergonomics in T4).
- `Game::log_evidence()` and `OwnedGame::log_evidence()` accessors.
### Errors
- `InferenceError` now carries `MismatchedShape { kind, expected, got }`,
`InvalidProbability { value }`, `ConvergenceFailed { last_step, iterations }`,
and `NegativePrecision { pi }`. Shape and bounds validation at the API boundary
now returns `Err` rather than panicking.
### Removed (breaking)
- `History::convergence(iters, eps, verbose)` — use `converge()`.
- `HistoryBuilder::gamma(f64)` — use `.drift(ConstantDrift(g))`.
- `HistoryBuilder::time(bool)` and `History.time: bool` — use the `Time` type parameter.
- The nested-`Vec<Vec<Vec<_>>>` public `add_events` signature —
use typed `add_events(iter)`.
- `learning_curves_by_index()` — use `learning_curves()`.
### Performance
`Batch::iteration` bench: **21.36 µs** (T1 was 22.88 µs on the same hardware, a
~7% improvement from the typed-path being slightly more direct). Gaussian
operations unchanged.
### Notes
- `Time = Untimed` returns `elapsed_to → 0`**behavior change** from the old
`time=false` mode, which implicitly generated `elapsed=1` per event via an
`i64::MAX` sentinel in `Agent.last_time`. Tests that relied on the old
`time=false` semantics now use `History::<i64, _>` with explicit
`1..=n` timestamps.
## 0.1.0 - 2026-04-23 ## 0.1.0 - 2026-04-23
@@ -156,6 +20,8 @@ operations unchanged.
- chore: added cliff.toml, release.toml and rustfmt.toml - chore: added cliff.toml, release.toml and rustfmt.toml
- chore: clean up - chore: clean up
- chore: make cargo release add CHANGELOG.md before commit
- chore: do not publish
### Other (unconventional) ### Other (unconventional)
+1
View File
@@ -51,6 +51,7 @@ fn build_history_1v1(
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: 30,
epsilon: 1e-6, epsilon: 1e-6,
alpha: 1.0,
}) })
.build(); .build();
@@ -49,7 +49,7 @@ A Gaussian `N(m, σ)` constructed via `Gaussian::from_ms(m, σ)`. Multiplication
**Concrete numerical check for tests:** With cavity `N(0, 6)` and observation `m_obs=5, σ=1`: **Concrete numerical check for tests:** With cavity `N(0, 6)` and observation `m_obs=5, σ=1`:
- `D_cav.pi = 1/36 ≈ 0.027778`, `D_cav.tau = 0`. - `D_cav.pi = 1/36 ≈ 0.027778`, `D_cav.tau = 0`.
- New marginal: `pi = 0.027778 + 1 = 1.027778`, `tau = 0 + 5 = 5`. So `mu = 5 / 1.027778 ≈ 4.864865`, `sigma = 1/sqrt(1.027778) ≈ 0.986394`. - New marginal: `pi = 0.027778 + 1 = 1.027778`, `tau = 0 + 5 = 5`. So `mu = 5 / 1.027778 ≈ 4.864865`, `sigma = 1/sqrt(1.027778) ≈ 0.986394`.
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.046827`. So `log_evidence ≈ -3.0613`. - `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.04678`. So `log_evidence ≈ -3.0622`.
--- ---
@@ -182,7 +182,7 @@ mod tests {
f.propagate(&mut vars); f.propagate(&mut vars);
let z = f.evidence_cached.unwrap(); let z = f.evidence_cached.unwrap();
// pdf(5, 0, sqrt(37)) ≈ 0.046827 // pdf(5, 0, sqrt(37)) ≈ 0.04678
assert!((z - 0.04682752233851171).abs() < 1e-10); assert!((z - 0.04682752233851171).abs() < 1e-10);
// Subsequent propagations don't change it. // Subsequent propagations don't change it.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,444 @@
# Tech Debt Cleanup 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:** Land three independent post-T4-MarginFactor cleanups: dedupe `Game::likelihoods` and `Game::likelihoods_scored` via a `run_chain` helper, make `BuiltinFactor::log_evidence` exhaustive, and fix stale numerics in the T4 plan doc.
**Architecture:** Pure code-shape and doc fixes. No public-API change, no behavioral change, no new dependencies. The dedup is a pure refactor — bit-equal posteriors and evidence against existing test goldens. The exhaustive match is a future-proofing change with no runtime effect. The doc fix is two number swaps in prose plus one matching code-comment swap.
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
---
## Spec reference
`docs/superpowers/specs/2026-05-08-tech-debt-cleanup-design.md`
## File map
| File | Why touched |
|---|---|
| `src/game.rs` | Add `run_chain` helper; rewrite `likelihoods` and `likelihoods_scored` to call it |
| `src/factor/mod.rs` | Make `BuiltinFactor::log_evidence` match exhaustive |
| `docs/superpowers/plans/2026-04-27-t4-margin-factor.md` | Fix two stale prose numbers and one matching code comment |
---
### Task 1: Extract `run_chain` helper, dedupe both likelihoods methods
**Files:**
- Modify: `src/game.rs:236-485` (replace both `likelihoods` and `likelihoods_scored` with one helper + two thin callers)
**Context for the implementer (read this before touching anything):**
`OwnedGame<T, D>` (defined at `src/game.rs:83-92`) holds `teams`, `result`, `weights`, `p_draw`, plus mutable output fields `likelihoods: Vec<Vec<Gaussian>>` and `evidence: f64`. Two private methods on `Game<'a, T, D>` (the borrowed sibling at `src/game.rs:148-156`) compute likelihoods:
- `likelihoods(&mut self, arena: &mut ScratchArena)` — ranked outcomes; `src/game.rs:236-371`
- `likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64)` — scored outcomes; `src/game.rs:373-485`
The two are bit-identical except for the closure that builds the per-diff `DiffFactor` (defined at `src/game.rs:20-54`). `DiffFactor` has two variants: `Trunc(TruncFactor)` for ranked, `Margin(MarginFactor)` for scored.
The shared body does, in order: `arena.reset()`, sort teams descending by `result` into `arena.sort_buf`, fill `arena.team_prior`, build `links: Vec<DiffFactor>` (the differing block), resize `arena.lhood_lose` / `arena.lhood_win` to `N_INF`, run a forward+backward sweep with a max-iter-10 fixed-point loop guarded by `tuple_gt(step, 1e-6)`, handle the `n_diffs == 1` special case, do boundary updates, multiply per-diff `evidence()` into `self.evidence`, build the inverse permutation in `arena.inv_buf`, then build `self.likelihoods` from the per-team `lhood_win * lhood_lose` and per-player `performance().exclude(...).forget(beta²)` math.
**Refactor target:**
```rust
fn run_chain<F>(
&self,
arena: &mut ScratchArena,
mut make_link: F,
) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
{ /* the entire shared body, returning (evidence, likelihoods) */ }
```
Helper takes `&self` (not `&mut self`) so the closure can capture `&self.result`, `&self.teams`, `&self.weights`, `&self.p_draw` without conflicting with the helper's own immutable borrow. The arena is borrowed `&mut` independently.
The closure is invoked once per diff index `i ∈ 0..n_diffs`, after `arena.sort_buf` is filled. It receives `i`, `&arena.sort_buf[..]`, and `&mut arena.vars` so it can `alloc(N_INF)` the diff `VarId`. It returns the constructed `DiffFactor`.
The two callers shrink to:
```rust
fn likelihoods(&mut self, arena: &mut ScratchArena) {
let p_draw = self.p_draw;
let result = &self.result;
let teams = &self.teams;
let (evidence, likelihoods) = Self::dummy_to_satisfy_borrowck(/* see below */);
// ... assigns self.evidence and self.likelihoods
}
```
Wait — actually borrow-checker note: calling `self.run_chain(arena, |i, sort_buf, vars| { use_self_fields })` from a `&mut self` method is **fine** because `run_chain` takes `&self` and the closure captures `&self` immutably. Both share an immutable reborrow of `*self`. The arena is a separate `&mut` borrow. Verify the implementer doesn't accidentally make `run_chain` take `&mut self`.
**Why a closure (not a trait, not a two-phase build).** A closure keeps caller-specific state (`p_draw`, `score_sigma`, beta sums) inline at the call site with zero ceremony. A trait would require a stateful builder per call. A two-phase build (caller produces `Vec<DiffFactor>` first, helper does the rest) would either re-do the sort or split arena ownership awkwardly between the phases.
---
- [ ] **Step 1: Run the existing test suite to capture the baseline**
Run: `cargo test --lib`
Expected: all tests pass. Note the count (should be 88+ lib tests) — the refactor must keep this number unchanged with all green.
- [ ] **Step 2: Open `src/game.rs` and add the `run_chain` helper**
Inside `impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> { ... }` (the block starting at `src/game.rs:158`), add `run_chain` immediately above the existing `likelihoods` method (so above line 236). Use exactly this body — it is the merge of the two existing methods with the differing block replaced by the closure call:
```rust
fn run_chain<F>(
&self,
arena: &mut ScratchArena,
mut make_link: F,
) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
{
arena.reset();
let n_teams = self.teams.len();
arena.sort_buf.extend(0..n_teams);
arena.sort_buf.sort_by(|&i, &j| {
self.result[j]
.partial_cmp(&self.result[i])
.unwrap_or(Ordering::Equal)
});
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
self.teams[t]
.iter()
.zip(self.weights[t].iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
}));
let n_diffs = n_teams.saturating_sub(1);
let mut links: Vec<DiffFactor> = (0..n_diffs)
.map(|i| make_link(i, &arena.sort_buf, &mut arena.vars))
.collect();
arena.lhood_lose.resize(n_teams, N_INF);
arena.lhood_win.resize(n_teams, N_INF);
let mut step = (f64::INFINITY, f64::INFINITY);
let mut iter = 0;
while tuple_gt(step, 1e-6) && iter < 10 {
step = (0.0_f64, 0.0_f64);
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars);
step = tuple_max(step, d);
let new_ll = pw - lf.msg();
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
arena.lhood_lose[e + 1] = new_ll;
}
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
let e = n_diffs - 1 - rev_i;
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars);
step = tuple_max(step, d);
let new_lw = pl + lf.msg();
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
arena.lhood_win[e] = new_lw;
}
iter += 1;
}
if n_diffs == 1 {
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
- (arena.team_prior[1] * arena.lhood_win[1]);
arena.vars.set(links[0].diff(), raw * links[0].msg());
links[0].propagate(&mut arena.vars);
}
if n_diffs > 0 {
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
arena.lhood_win[0] = pl1 + links[0].msg();
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
}
let evidence: f64 = links.iter().map(|l| l.evidence()).product();
arena.inv_buf.resize(n_teams, 0);
for (si, &orig_i) in arena.sort_buf.iter().enumerate() {
arena.inv_buf[orig_i] = si;
}
let likelihoods = self
.teams
.iter()
.zip(self.weights.iter())
.enumerate()
.map(|(orig_i, (players, weights))| {
let si = arena.inv_buf[orig_i];
let m = arena.lhood_win[si] * arena.lhood_lose[si];
let performance = players
.iter()
.zip(weights.iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w));
players
.iter()
.zip(weights.iter())
.map(|(player, &w)| {
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
.forget(player.beta.powi(2))
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
(evidence, likelihoods)
}
```
- [ ] **Step 3: Replace `likelihoods` body with a thin caller**
In `src/game.rs`, replace the entire body of `fn likelihoods(&mut self, arena: &mut ScratchArena)` (currently lines 236-371 — replace from the opening `{` to the closing `}` of that method) with:
```rust
fn likelihoods(&mut self, arena: &mut ScratchArena) {
let p_draw = self.p_draw;
// Capture pointers to fields the closure reads, to keep borrow scopes tight.
// Closure captures &self.result and &self.teams (both immutable) and the
// &mut arena passed in via run_chain — disjoint from `&self`.
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]];
let margin = if p_draw == 0.0 {
0.0
} else {
let a: f64 = self.teams[sort_buf[i]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
let b: f64 = self.teams[sort_buf[i + 1]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
compute_margin(p_draw, (a + b).sqrt())
};
let vid = vars.alloc(N_INF);
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
});
self.evidence = evidence;
self.likelihoods = likelihoods;
}
```
(Capturing `p_draw` as a local binding before the closure avoids a `self.p_draw` borrow inside; it's a `Copy` `f64` so this is free.)
- [ ] **Step 4: Replace `likelihoods_scored` body with a thin caller**
In `src/game.rs`, replace the entire body of `fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64)` (currently lines 373-485) with:
```rust
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
// After descending-by-score sort, m_obs >= 0 for every adjacent pair.
let m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]];
let vid = vars.alloc(N_INF);
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
});
self.evidence = evidence;
self.likelihoods = likelihoods;
}
```
- [ ] **Step 5: Build to confirm it compiles**
Run: `cargo build`
Expected: compiles cleanly. If the borrow checker complains that the closure conflicts with `self.run_chain(...)`, the most likely cause is `run_chain` accidentally being `&mut self` — confirm its signature is `fn run_chain<F>(&self, arena: &mut ScratchArena, mut make_link: F) -> (f64, Vec<Vec<Gaussian>>)`. If that's correct and there's still a conflict, double-check the closure's captures: it should capture `&self.result` and `&self.teams` (immutable), `p_draw: f64` by value (Copy), and `score_sigma: f64` by value (Copy). It must NOT touch `&mut self` in any form.
- [ ] **Step 6: Run the full library test suite — must be all green, same count as Step 1**
Run: `cargo test --lib`
Expected: same number of tests as Step 1, all pass. Bit-equal goldens — every existing assertion (`test_1vs1`, `test_1vs1_draw`, `test_2vs1vs2_mixed`, MarginFactor end-to-end tests, etc.) must pass unchanged. If ANY test fails, the refactor is wrong; revert and re-inspect.
- [ ] **Step 7: Run integration tests too**
Run: `cargo test`
Expected: all integration tests pass (28 noted in commit `8b53cac`).
- [ ] **Step 8: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
Expected: no diffs from fmt, no clippy warnings.
- [ ] **Step 9: Commit**
```bash
git add src/game.rs
git commit -m "$(cat <<'EOF'
refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
Both methods were 95-line near-duplicates differing only in the closure
that builds the per-diff DiffFactor. Extract the shared body as a
private run_chain<F>(&self, arena, make_link) helper that returns
(evidence, likelihoods); the two callers shrink to ~10 lines each.
Pure code-shape change: posteriors and evidence remain bit-equal; all
existing tests (lib + integration) pass unchanged.
EOF
)"
```
---
### Task 2: Make `BuiltinFactor::log_evidence` match exhaustive
**Files:**
- Modify: `src/factor/mod.rs:94-100` (the `log_evidence` impl on `BuiltinFactor`)
- [ ] **Step 1: Open `src/factor/mod.rs` and replace the `log_evidence` body**
Replace the existing impl:
```rust
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
_ => 0.0,
}
}
```
with:
```rust
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
}
}
```
- [ ] **Step 2: Build and run tests**
Run: `cargo build && cargo test --lib`
Expected: compiles cleanly, all tests pass. Behavior is unchanged — `TeamSum` and `RankDiff` still return `0.0`, but a future variant will now produce a non-exhaustive-match error instead of being silently swallowed.
- [ ] **Step 3: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --lib -- -D warnings`
Expected: no diffs, no warnings.
- [ ] **Step 4: Commit**
```bash
git add src/factor/mod.rs
git commit -m "$(cat <<'EOF'
refactor: make BuiltinFactor::log_evidence match exhaustive
Replace the `_ => 0.0` wildcard with explicit
`Self::TeamSum(_) | Self::RankDiff(_) => 0.0`. No behavioral change;
future variants now produce a compile error instead of being silently
absorbed by the wildcard.
EOF
)"
```
---
### Task 3: Fix stale numerics in T4 plan doc
**Files:**
- Modify: `docs/superpowers/plans/2026-04-27-t4-margin-factor.md` (lines 52 and 185)
The shipped test in `src/factor/mod.rs:163,166` asserts:
```
assert!((result.mu() - 4.864864864864865).abs() < 1e-12);
assert!((logz - (-3.062235327364623)).abs() < 1e-10);
```
The plan's prose at line 52 quotes pre-shipped values that no longer match. This task fixes the prose and the matching code-comment. The full-precision assertion blocks elsewhere in the plan are out of scope (they belong to the plan-as-written, and the spec's fix table only listed the rounded prose values).
- [ ] **Step 1: Update the prose at line 52**
Open `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`. Find the line:
```
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.046827`. So `log_evidence ≈ -3.0613`.
```
Replace with:
```
- `Z_cav = pdf(5, 0, sqrt(36 + 1)) = pdf(5, 0, sqrt(37)) ≈ 0.04678`. So `log_evidence ≈ -3.0622`.
```
- [ ] **Step 2: Update the matching code-comment at line 185**
In the same file, find:
```
// pdf(5, 0, sqrt(37)) ≈ 0.046827
```
Replace with:
```
// pdf(5, 0, sqrt(37)) ≈ 0.04678
```
- [ ] **Step 3: Verify nothing else changed**
Run: `git diff docs/superpowers/plans/2026-04-27-t4-margin-factor.md`
Expected: exactly three lines changed (one prose line containing both numbers, one comment line). Nothing else should be touched.
- [ ] **Step 4: Commit**
```bash
git add docs/superpowers/plans/2026-04-27-t4-margin-factor.md
git commit -m "$(cat <<'EOF'
docs: fix stale numerics in t4-margin-factor plan
The plan's prose quoted Z_cav ≈ 0.046827 and log_evidence ≈ -3.0613,
which diverged from the values asserted by the shipped test in
src/factor/mod.rs (-3.062235327364623). Update prose and the matching
code comment to 0.04678 / -3.0622.
EOF
)"
```
---
## Self-review (writer's note)
Spec coverage:
- Spec Item 1 (dedupe `likelihoods`/`likelihoods_scored`) → Task 1 ✓
- Spec Item 2 (exhaustive `BuiltinFactor::log_evidence`) → Task 2 ✓
- Spec Item 3 (stale numerics in T4 plan) → Task 3 ✓
- Spec out-of-scope items (`DiffFactor` collapse, per-event `score_sigma`) — correctly absent ✓
Verification gates per the spec ("each item commits independently and ships behind a green `cargo test --lib`"): every task ends in fmt + clippy + tests + commit. Task 1 additionally runs `cargo test` for integration coverage.
Type / signature consistency:
- `run_chain` signature appears identically in the context header and Step 2 body ✓
- Closure type `FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor` matches across Step 2 (definition) and Steps 3/4 (call sites) ✓
- `DiffFactor::Trunc` / `DiffFactor::Margin` constructors match `src/game.rs:20-23` definitions ✓
No placeholders detected.
@@ -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<f64> 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<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:
```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 +58 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).
@@ -0,0 +1,134 @@
# Tech Debt Cleanup — Post-T4-MarginFactor
## Summary
Three small, independent cleanups left behind by the T4-MarginFactor merge
(`8b53cac`). All three are pure code-shape or doc fixes. No public-API change,
no numerics change, no new behavior.
This batch deliberately excludes the `DiffFactor``BuiltinFactor` overlap
collapse (architectural change kept separate) and per-event `score_sigma`
override (a feature, not debt).
## Scope
### Item 1 — Deduplicate `Game::likelihoods` and `Game::likelihoods_scored`
**Current state.** `src/game.rs:236-371` and `src/game.rs:373-485` are 95-line
near-duplicates of each other. They differ in exactly one block: the closure
that maps a diff index to a `DiffFactor`. The ranked path builds
`DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))` with `margin`/`tie`
derived from `p_draw` and adjacent-result equality. The scored path builds
`DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))` with `m_obs`
the observed score gap. Everything else — sort, `team_prior`, sweep loop,
boundary updates, evidence product, posterior `likelihoods` — is bit-identical.
**Refactor.** Extract a private helper on `OwnedGame<T, D>`:
```rust
fn run_chain<F>(
&self,
arena: &mut ScratchArena,
make_link: F,
) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut VarStore) -> DiffFactor,
```
The closure receives the diff index `i`, the descending-by-result sort
permutation `&arena.sort_buf`, and `&mut arena.vars` for `alloc(N_INF)`. It
returns the `DiffFactor` for that diff slot.
The helper takes `&self` (not `&mut self`) and returns
`(evidence, likelihoods)`. Each caller writes the results back to its own
`self.evidence` and `self.likelihoods` fields. The `&self` choice matters: the
closure captures `&self.result` / `&self.teams` / `&self.weights` / `&self.p_draw`
freely without conflicting with the helper's own immutable borrow.
The two public methods shrink from ~125 lines each to ~10 lines that just
construct the closure.
**Why a closure (not a trait or two-phase build).** A closure keeps all
caller-specific state (`p_draw`, `score_sigma`, beta sums for margin) inline at
the call site. A trait would require a stateful object per call; a two-phase
build (caller produces the `Vec<DiffFactor>` first, helper does the rest) would
either re-do the sort or split state ownership awkwardly between phases.
### Item 2 — Make `BuiltinFactor::log_evidence` exhaustive
**Current state.** `src/factor/mod.rs:94-100` uses a `_ => 0.0` wildcard for
`TeamSum` and `RankDiff`. When a future variant lands (e.g. `SynergyFactor`),
the wildcard silently absorbs it instead of forcing a deliberate decision.
**Refactor.**
```rust
fn log_evidence(&self, vars: &VarStore) -> f64 {
match self {
Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars),
Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
}
}
```
No behavioral change. Future variants now produce a non-exhaustive-match
compile error.
### Item 3 — Fix stale numerics in T4 plan doc
**Current state.** `docs/superpowers/plans/2026-04-27-t4-margin-factor.md`
contains two numbers that diverge from the values asserted by the shipped test
in `src/factor/mod.rs:163,166`.
**Fix.**
| Doc value (wrong) | Implementation value (correct) |
|---|---|
| `0.046827` | `0.04678` |
| `-3.0613` | `-3.0622` |
Pure docs change. Verified by reading the asserted constants in the test.
## Out of scope
- **`DiffFactor``BuiltinFactor` overlap.** Both enums list `Trunc` and
`Margin` variants. Collapsing into `BuiltinFactor::Diff(DiffFactor)` is
defensible but is an architectural change that wants its own design pass.
`DiffFactor` represents a real semantic subset (factors that operate on a
diff variable in a chain); the duplication is two enum variants, not a
large block of code.
- **Per-event `EventKind::Scored.score_sigma` override.** Today
`score_sigma` is history-wide (set on `HistoryBuilder::score_sigma`). A
per-event override is a real feature ask, not tech debt.
## Verification
Each item commits independently and ships behind a green `cargo test --lib`
run. The dedup is a pure code-shape change: posteriors and evidence must be
**bit-equal** (not ULP-bounded) against the existing 88+28 test goldens.
Per-item gate before committing:
```bash
cargo +nightly fmt
cargo clippy
cargo test --lib
```
## Commit shape
Three commits, one per item, each independently revertable:
1. `refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain`
2. `refactor: make BuiltinFactor::log_evidence match exhaustive`
3. `docs: fix stale numerics in t4-margin-factor plan`
## Risks
- **Borrow-checker friction in Item 1.** The closure captures fields of
`&self` while the helper iterates over arena state. Mitigation: helper is
`&self` (not `&mut self`); arena passed as `&mut ScratchArena` separately.
Disjoint borrows.
- **Compile error in Item 2 if a new variant ships before this lands.**
Trivial follow-on; the whole point is to surface that signal.
+1
View File
@@ -48,6 +48,7 @@ fn main() {
.convergence(trueskill_tt::ConvergenceOptions { .convergence(trueskill_tt::ConvergenceOptions {
max_iter: 10, max_iter: 10,
epsilon: 0.01, epsilon: 0.01,
alpha: 1.0,
}) })
.build(); .build();
+1 -1
View File
@@ -1,2 +1,2 @@
publish = false publish = false
pre-release-hook = ["sh", "-c", "git cliff -o ../CHANGELOG.md --tag {{version}} && git add CHANGELOG.md"] pre-release-hook = ["sh", "-c", "git cliff -o CHANGELOG.md --tag {{version}} && git add CHANGELOG.md"]
+17
View File
@@ -8,6 +8,11 @@ use smallvec::SmallVec;
pub struct ConvergenceOptions { pub struct ConvergenceOptions {
pub max_iter: usize, pub max_iter: usize,
pub epsilon: f64, 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 { impl Default for ConvergenceOptions {
@@ -15,6 +20,7 @@ impl Default for ConvergenceOptions {
Self { Self {
max_iter: crate::ITERATIONS, max_iter: crate::ITERATIONS,
epsilon: crate::EPSILON, epsilon: crate::EPSILON,
alpha: 1.0,
} }
} }
} }
@@ -29,3 +35,14 @@ pub struct ConvergenceReport {
pub per_iteration_time: SmallVec<[Duration; 32]>, pub per_iteration_time: SmallVec<[Duration; 32]>,
pub slices_skipped: usize, pub slices_skipped: usize,
} }
#[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);
}
}
+60 -6
View File
@@ -32,8 +32,11 @@ impl MarginFactor {
} }
} }
impl Factor for MarginFactor { impl MarginFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { /// 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 marginal = vars.get(self.diff);
let cavity = marginal / self.msg; let cavity = marginal / self.msg;
@@ -42,12 +45,18 @@ impl Factor for MarginFactor {
} }
let new_msg = Gaussian::from_ms(self.m_obs, self.sigma); let new_msg = Gaussian::from_ms(self.m_obs, self.sigma);
let new_marginal = cavity * new_msg; let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg; let old_msg = self.msg;
self.msg = new_msg; self.msg = damped;
vars.set(self.diff, new_marginal); vars.set(self.diff, cavity * damped);
old_msg.delta(new_msg) 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 { fn log_evidence(&self, _vars: &VarStore) -> f64 {
@@ -120,4 +129,49 @@ mod tests {
let logz = f.log_evidence(&vars); let logz = f.log_evidence(&vars);
assert!((logz - (-3.062235327364623)).abs() < 1e-10); assert!((logz - (-3.062235327364623)).abs() < 1e-10);
} }
#[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);
}
} }
+1 -1
View File
@@ -95,7 +95,7 @@ impl Factor for BuiltinFactor {
match self { match self {
Self::Trunc(f) => f.log_evidence(vars), Self::Trunc(f) => f.log_evidence(vars),
Self::Margin(f) => f.log_evidence(vars), Self::Margin(f) => f.log_evidence(vars),
_ => 0.0, Self::TeamSum(_) | Self::RankDiff(_) => 0.0,
} }
} }
} }
+64 -11
View File
@@ -33,29 +33,37 @@ impl TruncFactor {
} }
} }
impl Factor for TruncFactor { impl TruncFactor {
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64) { /// 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 marginal = vars.get(self.diff);
// Cavity: marginal divided by our outgoing message.
let cavity = marginal / self.msg; let cavity = marginal / self.msg;
// First-time-only: cache the evidence contribution from the cavity.
if self.evidence_cached.is_none() { if self.evidence_cached.is_none() {
self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie)); self.evidence_cached = Some(cavity_evidence(cavity, self.margin, self.tie));
} }
// Apply the truncation approximation to the cavity.
let trunc = approx(cavity, self.margin, self.tie); let trunc = approx(cavity, self.margin, self.tie);
// New outgoing message such that cavity * new_msg = trunc.
let new_msg = trunc / cavity; let new_msg = trunc / cavity;
let damped = self.msg.damp_natural(new_msg, alpha);
let old_msg = self.msg; let old_msg = self.msg;
self.msg = new_msg; self.msg = damped;
// Update the marginal: marginal_new = cavity * new_msg = trunc. // marginal_new = cavity * stored_msg. With alpha = 1.0 this equals
vars.set(self.diff, trunc); // `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(new_msg) 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 { fn log_evidence(&self, _vars: &VarStore) -> f64 {
@@ -127,4 +135,49 @@ mod tests {
let ev = f.evidence_cached.unwrap(); let ev = f.evidence_cached.unwrap();
assert!(ev > 0.35 && ev < 0.42); assert!(ev > 0.35 && ev < 0.42);
} }
#[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);
}
} }
+224 -155
View File
@@ -44,11 +44,14 @@ impl DiffFactor {
} }
} }
pub(crate) fn propagate(&mut self, vars: &mut crate::factor::VarStore) -> (f64, f64) { pub(crate) fn propagate(
use crate::factor::Factor; &mut self,
vars: &mut crate::factor::VarStore,
alpha: f64,
) -> (f64, f64) {
match self { match self {
Self::Trunc(f) => f.propagate(vars), Self::Trunc(f) => f.propagate_with_alpha(vars, alpha),
Self::Margin(f) => f.propagate(vars), Self::Margin(f) => f.propagate_with_alpha(vars, alpha),
} }
} }
} }
@@ -87,6 +90,7 @@ pub struct OwnedGame<T: Time, D: Drift<T>> {
result: Vec<f64>, result: Vec<f64>,
weights: Vec<Vec<f64>>, weights: Vec<Vec<f64>>,
p_draw: f64, p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>, pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64, pub(crate) evidence: f64,
} }
@@ -97,9 +101,17 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
result: Vec<f64>, result: Vec<f64>,
weights: Vec<Vec<f64>>, weights: Vec<Vec<f64>>,
p_draw: f64, p_draw: f64,
convergence: crate::ConvergenceOptions,
) -> Self { ) -> Self {
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
let g = Game::ranked_with_arena(teams.clone(), &result, &weights, p_draw, &mut arena); let g = Game::ranked_with_arena(
teams.clone(),
&result,
&weights,
p_draw,
convergence,
&mut arena,
);
let likelihoods = g.likelihoods; let likelihoods = g.likelihoods;
let evidence = g.evidence; let evidence = g.evidence;
Self { Self {
@@ -107,6 +119,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
result, result,
weights, weights,
p_draw, p_draw,
convergence,
likelihoods, likelihoods,
evidence, evidence,
} }
@@ -117,9 +130,17 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
scores: Vec<f64>, scores: Vec<f64>,
weights: Vec<Vec<f64>>, weights: Vec<Vec<f64>>,
score_sigma: f64, score_sigma: f64,
convergence: crate::ConvergenceOptions,
) -> Self { ) -> Self {
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
let g = Game::scored_with_arena(teams.clone(), &scores, &weights, score_sigma, &mut arena); let g = Game::scored_with_arena(
teams.clone(),
&scores,
&weights,
score_sigma,
convergence,
&mut arena,
);
let likelihoods = g.likelihoods; let likelihoods = g.likelihoods;
let evidence = g.evidence; let evidence = g.evidence;
Self { Self {
@@ -127,6 +148,7 @@ impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
result: scores, result: scores,
weights, weights,
p_draw: 0.0, p_draw: 0.0,
convergence,
likelihoods, likelihoods,
evidence, evidence,
} }
@@ -151,6 +173,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
result: &'a [f64], result: &'a [f64],
weights: &'a [Vec<f64>], weights: &'a [Vec<f64>],
p_draw: f64, p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
pub(crate) likelihoods: Vec<Vec<Gaussian>>, pub(crate) likelihoods: Vec<Vec<Gaussian>>,
pub(crate) evidence: f64, pub(crate) evidence: f64,
} }
@@ -161,6 +184,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
result: &'a [f64], result: &'a [f64],
weights: &'a [Vec<f64>], weights: &'a [Vec<f64>],
p_draw: f64, p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena, arena: &mut ScratchArena,
) -> Self { ) -> Self {
debug_assert!( debug_assert!(
@@ -186,12 +210,17 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
}, },
"draw must be > 0.0 if there are teams with draw" "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 { let mut this = Self {
teams, teams,
result, result,
weights, weights,
p_draw, p_draw,
convergence,
likelihoods: Vec::new(), likelihoods: Vec::new(),
evidence: 0.0, evidence: 0.0,
}; };
@@ -205,6 +234,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
scores: &'a [f64], scores: &'a [f64],
weights: &'a [Vec<f64>], weights: &'a [Vec<f64>],
score_sigma: f64, score_sigma: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena, arena: &mut ScratchArena,
) -> Self { ) -> Self {
debug_assert!( debug_assert!(
@@ -219,12 +249,17 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
"weights must have the same dimensions as teams" "weights must have the same dimensions as teams"
); );
debug_assert!(score_sigma > 0.0, "score_sigma must be positive"); 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 { let mut this = Self {
teams, teams,
result: scores, result: scores,
weights, weights,
p_draw: 0.0, p_draw: 0.0,
convergence,
likelihoods: Vec::new(), likelihoods: Vec::new(),
evidence: 0.0, evidence: 0.0,
}; };
@@ -233,12 +268,18 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
this this
} }
fn likelihoods(&mut self, arena: &mut ScratchArena) { fn run_chain<F>(&self, arena: &mut ScratchArena, mut make_link: F) -> (f64, Vec<Vec<Gaussian>>)
where
F: FnMut(usize, &[usize], &mut crate::factor::VarStore) -> DiffFactor,
{
arena.reset(); arena.reset();
let alpha = self.convergence.alpha;
let epsilon = self.convergence.epsilon;
let max_iter = self.convergence.max_iter;
let n_teams = self.teams.len(); let n_teams = self.teams.len();
// Sort teams by result descending; reuse arena.sort_buf to avoid allocation.
arena.sort_buf.extend(0..n_teams); arena.sort_buf.extend(0..n_teams);
arena.sort_buf.sort_by(|&i, &j| { arena.sort_buf.sort_by(|&i, &j| {
self.result[j] self.result[j]
@@ -246,7 +287,6 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.unwrap_or(Ordering::Equal) .unwrap_or(Ordering::Equal)
}); });
// Team performance priors written into arena buffer (capacity reused across games).
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| { arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
self.teams[t] self.teams[t]
.iter() .iter()
@@ -256,46 +296,25 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
let n_diffs = n_teams.saturating_sub(1); let n_diffs = n_teams.saturating_sub(1);
// One DiffFactor per adjacent sorted-team pair; each owns a diff VarId.
// links stays local (fresh state per game; Vec capacity is typically small).
let mut links: Vec<DiffFactor> = (0..n_diffs) let mut links: Vec<DiffFactor> = (0..n_diffs)
.map(|i| { .map(|i| make_link(i, &arena.sort_buf, &mut arena.vars))
let tie = self.result[arena.sort_buf[i]] == self.result[arena.sort_buf[i + 1]];
let margin = if self.p_draw == 0.0 {
0.0
} else {
let a: f64 = self.teams[arena.sort_buf[i]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
let b: f64 = self.teams[arena.sort_buf[i + 1]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
compute_margin(self.p_draw, (a + b).sqrt())
};
let vid = arena.vars.alloc(N_INF);
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
})
.collect(); .collect();
// Per-team messages from neighbouring RankDiff factors (replaces TeamMessage).
arena.lhood_lose.resize(n_teams, N_INF); arena.lhood_lose.resize(n_teams, N_INF);
arena.lhood_win.resize(n_teams, N_INF); arena.lhood_win.resize(n_teams, N_INF);
let mut step = (f64::INFINITY, f64::INFINITY); let mut step = (f64::INFINITY, f64::INFINITY);
let mut iter = 0; let mut iter = 0;
while tuple_gt(step, 1e-6) && iter < 10 { while tuple_gt(step, epsilon) && iter < max_iter {
step = (0.0_f64, 0.0_f64); step = (0.0_f64, 0.0_f64);
// Forward sweep: diffs 0 .. n_diffs-2 (all but the last).
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() { for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
let pw = arena.team_prior[e] * arena.lhood_lose[e]; let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1]; let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl; let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg()); arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars); let d = lf.propagate(&mut arena.vars, alpha);
step = tuple_max(step, d); step = tuple_max(step, d);
let new_ll = pw - lf.msg(); let new_ll = pw - lf.msg();
@@ -303,14 +322,13 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
arena.lhood_lose[e + 1] = new_ll; arena.lhood_lose[e + 1] = new_ll;
} }
// Backward sweep: diffs n_diffs-1 .. 1 (reverse, all but the first).
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() { for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
let e = n_diffs - 1 - rev_i; let e = n_diffs - 1 - rev_i;
let pw = arena.team_prior[e] * arena.lhood_lose[e]; let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1]; let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl; let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg()); arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars); let d = lf.propagate(&mut arena.vars, alpha);
step = tuple_max(step, d); step = tuple_max(step, d);
let new_lw = pl + lf.msg(); let new_lw = pl + lf.msg();
@@ -326,7 +344,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
let raw = (arena.team_prior[0] * arena.lhood_lose[0]) let raw = (arena.team_prior[0] * arena.lhood_lose[0])
- (arena.team_prior[1] * arena.lhood_win[1]); - (arena.team_prior[1] * arena.lhood_win[1]);
arena.vars.set(links[0].diff(), raw * links[0].msg()); arena.vars.set(links[0].diff(), raw * links[0].msg());
links[0].propagate(&mut arena.vars); links[0].propagate(&mut arena.vars, alpha);
} }
// Boundary updates: close the chain at both ends. // Boundary updates: close the chain at both ends.
@@ -337,8 +355,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg(); arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
} }
// Evidence = product of per-diff evidences (each cached on first propagation). let evidence: f64 = links.iter().map(|l| l.evidence()).product();
self.evidence = links.iter().map(|l| l.evidence()).product();
// Inverse permutation: inv_buf[orig_i] = sorted_i. // Inverse permutation: inv_buf[orig_i] = sorted_i.
arena.inv_buf.resize(n_teams, 0); arena.inv_buf.resize(n_teams, 0);
@@ -346,7 +363,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
arena.inv_buf[orig_i] = si; arena.inv_buf[orig_i] = si;
} }
self.likelihoods = self let likelihoods = self
.teams .teams
.iter() .iter()
.zip(self.weights.iter()) .zip(self.weights.iter())
@@ -368,120 +385,38 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
(evidence, likelihoods)
}
fn likelihoods(&mut self, arena: &mut ScratchArena) {
let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let tie = self.result[sort_buf[i]] == self.result[sort_buf[i + 1]];
let margin = if self.p_draw == 0.0 {
0.0
} else {
let a: f64 = self.teams[sort_buf[i]].iter().map(|p| p.beta.powi(2)).sum();
let b: f64 = self.teams[sort_buf[i + 1]]
.iter()
.map(|p| p.beta.powi(2))
.sum();
compute_margin(self.p_draw, (a + b).sqrt())
};
let vid = vars.alloc(N_INF);
DiffFactor::Trunc(TruncFactor::new(vid, margin, tie))
});
self.evidence = evidence;
self.likelihoods = likelihoods;
} }
fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) { fn likelihoods_scored(&mut self, arena: &mut ScratchArena, score_sigma: f64) {
arena.reset(); let (evidence, likelihoods) = self.run_chain(arena, |i, sort_buf, vars| {
let m_obs = self.result[sort_buf[i]] - self.result[sort_buf[i + 1]];
let n_teams = self.teams.len(); let vid = vars.alloc(N_INF);
arena.sort_buf.extend(0..n_teams);
arena.sort_buf.sort_by(|&i, &j| {
self.result[j]
.partial_cmp(&self.result[i])
.unwrap_or(Ordering::Equal)
});
arena.team_prior.extend(arena.sort_buf.iter().map(|&t| {
self.teams[t]
.iter()
.zip(self.weights[t].iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
}));
let n_diffs = n_teams.saturating_sub(1);
let mut links: Vec<DiffFactor> = (0..n_diffs)
.map(|i| {
// After descending-by-score sort, m_obs >= 0 for every adjacent pair.
let m_obs = self.result[arena.sort_buf[i]] - self.result[arena.sort_buf[i + 1]];
let vid = arena.vars.alloc(N_INF);
DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma)) DiffFactor::Margin(MarginFactor::new(vid, m_obs, score_sigma))
}) });
.collect(); self.evidence = evidence;
self.likelihoods = likelihoods;
arena.lhood_lose.resize(n_teams, N_INF);
arena.lhood_win.resize(n_teams, N_INF);
let mut step = (f64::INFINITY, f64::INFINITY);
let mut iter = 0;
while tuple_gt(step, 1e-6) && iter < 10 {
step = (0.0_f64, 0.0_f64);
for (e, lf) in links[..n_diffs.saturating_sub(1)].iter_mut().enumerate() {
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars);
step = tuple_max(step, d);
let new_ll = pw - lf.msg();
step = tuple_max(step, arena.lhood_lose[e + 1].delta(new_ll));
arena.lhood_lose[e + 1] = new_ll;
}
for (rev_i, lf) in links[1..].iter_mut().rev().enumerate() {
let e = n_diffs - 1 - rev_i;
let pw = arena.team_prior[e] * arena.lhood_lose[e];
let pl = arena.team_prior[e + 1] * arena.lhood_win[e + 1];
let raw = pw - pl;
arena.vars.set(lf.diff(), raw * lf.msg());
let d = lf.propagate(&mut arena.vars);
step = tuple_max(step, d);
let new_lw = pl + lf.msg();
step = tuple_max(step, arena.lhood_win[e].delta(new_lw));
arena.lhood_win[e] = new_lw;
}
iter += 1;
}
if n_diffs == 1 {
let raw = (arena.team_prior[0] * arena.lhood_lose[0])
- (arena.team_prior[1] * arena.lhood_win[1]);
arena.vars.set(links[0].diff(), raw * links[0].msg());
links[0].propagate(&mut arena.vars);
}
if n_diffs > 0 {
let pl1 = arena.team_prior[1] * arena.lhood_win[1];
arena.lhood_win[0] = pl1 + links[0].msg();
let pw_last = arena.team_prior[n_teams - 2] * arena.lhood_lose[n_teams - 2];
arena.lhood_lose[n_teams - 1] = pw_last - links[n_diffs - 1].msg();
}
self.evidence = links.iter().map(|l| l.evidence()).product();
arena.inv_buf.resize(n_teams, 0);
for (si, &orig_i) in arena.sort_buf.iter().enumerate() {
arena.inv_buf[orig_i] = si;
}
self.likelihoods = self
.teams
.iter()
.zip(self.weights.iter())
.enumerate()
.map(|(orig_i, (players, weights))| {
let si = arena.inv_buf[orig_i];
let m = arena.lhood_win[si] * arena.lhood_lose[si];
let performance = players
.iter()
.zip(weights.iter())
.fold(N00, |p, (player, &w)| p + (player.performance() * w));
players
.iter()
.zip(weights.iter())
.map(|(player, &w)| {
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
.forget(player.beta.powi(2))
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
} }
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> { pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
@@ -533,7 +468,13 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect(); let teams_owned: Vec<Vec<Rating<T, D>>> = teams.iter().map(|t| t.to_vec()).collect();
let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect(); let weights: Vec<Vec<f64>> = teams.iter().map(|t| vec![1.0; t.len()]).collect();
Ok(OwnedGame::new(teams_owned, result, weights, options.p_draw)) Ok(OwnedGame::new(
teams_owned,
result,
weights,
options.p_draw,
options.convergence,
))
} }
pub fn scored( pub fn scored(
@@ -569,6 +510,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
scores, scores,
weights, weights,
options.score_sigma, options.score_sigma,
options.convergence,
)) ))
} }
@@ -630,6 +572,7 @@ mod tests {
&[0.0, 1.0], &[0.0, 1.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -657,6 +600,7 @@ mod tests {
&[0.0, 1.0], &[0.0, 1.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -676,6 +620,7 @@ mod tests {
&[0.0, 1.0], &[0.0, 1.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
@@ -709,6 +654,7 @@ mod tests {
&[1.0, 2.0, 0.0], &[1.0, 2.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -725,6 +671,7 @@ mod tests {
&[2.0, 1.0, 0.0], &[2.0, 1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -736,7 +683,14 @@ mod tests {
assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6); assert_ulps_eq!(b, Gaussian::from_ms(25.000000, 6.238469), epsilon = 1e-6);
let w = [vec![1.0], vec![1.0], vec![1.0]]; let w = [vec![1.0], vec![1.0], vec![1.0]];
let g = Game::ranked_with_arena(teams, &[1.0, 2.0, 0.0], &w, 0.5, &mut ScratchArena::new()); let g = Game::ranked_with_arena(
teams,
&[1.0, 2.0, 0.0],
&w,
0.5,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
);
let p = g.posteriors(); let p = g.posteriors();
let a = p[0][0]; let a = p[0][0];
@@ -768,6 +722,7 @@ mod tests {
&[0.0, 0.0], &[0.0, 0.0],
&w, &w,
0.25, 0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -795,6 +750,7 @@ mod tests {
&[0.0, 0.0], &[0.0, 0.0],
&w, &w,
0.25, 0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -830,6 +786,7 @@ mod tests {
&[0.0, 0.0, 0.0], &[0.0, 0.0, 0.0],
&w, &w,
0.25, 0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -866,6 +823,7 @@ mod tests {
&[0.0, 0.0, 0.0], &[0.0, 0.0, 0.0],
&w, &w,
0.25, 0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -917,6 +875,7 @@ mod tests {
&[1.0, 0.0, 0.0], &[1.0, 0.0, 0.0],
&w, &w,
0.25, 0.25,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -950,6 +909,7 @@ mod tests {
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -974,6 +934,7 @@ mod tests {
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -998,6 +959,7 @@ mod tests {
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -1025,6 +987,7 @@ mod tests {
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -1052,6 +1015,7 @@ mod tests {
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -1071,8 +1035,8 @@ mod tests {
let mut t = DiffFactor::Trunc(TruncFactor::new(dt, 0.0, false)); let mut t = DiffFactor::Trunc(TruncFactor::new(dt, 0.0, false));
let mut m = DiffFactor::Margin(MarginFactor::new(dm, 5.0, 1.0)); let mut m = DiffFactor::Margin(MarginFactor::new(dm, 5.0, 1.0));
let _ = t.propagate(&mut vars); let _ = t.propagate(&mut vars, 1.0);
let _ = m.propagate(&mut vars); let _ = m.propagate(&mut vars, 1.0);
// Smoke: both diffs got written; their msgs are non-N_INF. // Smoke: both diffs got written; their msgs are non-N_INF.
assert!(t.msg().pi() > 0.0); assert!(t.msg().pi() > 0.0);
@@ -1093,7 +1057,11 @@ mod tests {
let weights = [vec![1.0], vec![1.0]]; let weights = [vec![1.0], vec![1.0]];
let mut arena = ScratchArena::new(); let mut arena = ScratchArena::new();
let g = Game::scored_with_arena( let g = Game::scored_with_arena(
teams, &result, &weights, 1.0, // score_sigma teams,
&result,
&weights,
1.0,
crate::ConvergenceOptions::default(),
&mut arena, &mut arena,
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -1112,7 +1080,8 @@ mod tests {
vec![vec![prior], vec![prior]], vec![vec![prior], vec![prior]],
&result, &result,
&weights, &weights,
0.1, // tighter score_sigma 0.1,
crate::ConvergenceOptions::default(),
&mut arena2, &mut arena2,
); );
let p_tight = g_tight.posteriors(); let p_tight = g_tight.posteriors();
@@ -1220,6 +1189,7 @@ mod tests {
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -1254,6 +1224,7 @@ mod tests {
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -1288,6 +1259,7 @@ mod tests {
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -1326,6 +1298,7 @@ mod tests {
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let post_2vs1 = g.posteriors(); let post_2vs1 = g.posteriors();
@@ -1339,6 +1312,7 @@ mod tests {
&[1.0, 0.0], &[1.0, 0.0],
&w, &w,
0.0, 0.0,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
); );
let p = g.posteriors(); let p = g.posteriors();
@@ -1348,4 +1322,99 @@ mod tests {
assert_ulps_eq!(p[1][0], post_2vs1[1][0], epsilon = 1e-6); assert_ulps_eq!(p[1][0], post_2vs1[1][0], epsilon = 1e-6);
assert_ulps_eq!(p[1][1], t_b[1].prior, epsilon = 1e-6); assert_ulps_eq!(p[1][1], t_b[1].prior, epsilon = 1e-6);
} }
#[test]
fn run_chain_honours_max_iter_in_convergence_options() {
let players: Vec<R> = (0..4).map(|_| R::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,
crate::ConvergenceOptions {
max_iter: 1,
..crate::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,
crate::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() {
let players: Vec<R> = (0..4).map(|_| R::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,
crate::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,
crate::ConvergenceOptions {
alpha: 0.5,
max_iter: 100,
..crate::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}"
);
}
} }
+41
View File
@@ -96,6 +96,18 @@ impl Gaussian {
let var = self.sigma().powi(2) + variance_delta; let var = self.sigma().powi(2) + variance_delta;
Self::from_ms(self.mu(), var.sqrt()) Self::from_ms(self.mu(), var.sqrt())
} }
/// 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(),
)
}
} }
impl Default for Gaussian { impl Default for Gaussian {
@@ -231,4 +243,33 @@ mod tests {
assert!((r.pi() - expected_pi).abs() < 1e-15); assert!((r.pi() - expected_pi).abs() < 1e-15);
assert!((r.tau() - expected_tau).abs() < 1e-15); assert!((r.tau() - expected_tau).abs() < 1e-15);
} }
#[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);
}
} }
+3
View File
@@ -838,6 +838,7 @@ mod tests {
&[0.0, 1.0], &[0.0, 1.0],
&w, &w,
P_DRAW, P_DRAW,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(), &mut ScratchArena::new(),
) )
.posteriors(); .posteriors();
@@ -1368,6 +1369,7 @@ mod tests {
h.convergence = ConvergenceOptions { h.convergence = ConvergenceOptions {
max_iter: 11, max_iter: 11,
epsilon: EPSILON, epsilon: EPSILON,
alpha: 1.0,
}; };
h.converge().unwrap(); h.converge().unwrap();
@@ -1685,6 +1687,7 @@ mod tests {
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: 30,
epsilon: 1e-6, epsilon: 1e-6,
alpha: 1.0,
}) })
.build(); .build();
+36 -14
View File
@@ -138,12 +138,22 @@ impl Event {
let teams = self.within_priors(false, false, skills, agents); let teams = self.within_priors(false, false, skills, agents);
let result = self.outputs(); let result = self.outputs();
let g = match self.kind { let g = match self.kind {
EventKind::Ranked => { EventKind::Ranked => Game::ranked_with_arena(
Game::ranked_with_arena(teams, &result, &self.weights, p_draw, arena) teams,
} &result,
EventKind::Scored { score_sigma } => { &self.weights,
Game::scored_with_arena(teams, &result, &self.weights, score_sigma, arena) p_draw,
} crate::ConvergenceOptions::default(),
arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&self.weights,
score_sigma,
crate::ConvergenceOptions::default(),
arena,
),
}; };
for (t, team) in self.teams.iter_mut().enumerate() { for (t, team) in self.teams.iter_mut().enumerate() {
@@ -322,6 +332,7 @@ impl<T: Time> TimeSlice<T> {
&result, &result,
&event.weights, &event.weights,
self.p_draw, self.p_draw,
crate::ConvergenceOptions::default(),
&mut self.arena, &mut self.arena,
), ),
EventKind::Scored { score_sigma } => Game::scored_with_arena( EventKind::Scored { score_sigma } => Game::scored_with_arena(
@@ -329,6 +340,7 @@ impl<T: Time> TimeSlice<T> {
&result, &result,
&event.weights, &event.weights,
score_sigma, score_sigma,
crate::ConvergenceOptions::default(),
&mut self.arena, &mut self.arena,
), ),
}; };
@@ -504,16 +516,26 @@ impl<T: Time> TimeSlice<T> {
let teams = event.within_priors(online, forward, &self.skills, agents); let teams = event.within_priors(online, forward, &self.skills, agents);
let result = event.outputs(); let result = event.outputs();
match event.kind { match event.kind {
EventKind::Ranked => { EventKind::Ranked => Game::ranked_with_arena(
Game::ranked_with_arena(teams, &result, &event.weights, self.p_draw, arena) teams,
&result,
&event.weights,
self.p_draw,
crate::ConvergenceOptions::default(),
arena,
)
.evidence .evidence
.ln() .ln(),
} EventKind::Scored { score_sigma } => Game::scored_with_arena(
EventKind::Scored { score_sigma } => { teams,
Game::scored_with_arena(teams, &result, &event.weights, score_sigma, arena) &result,
&event.weights,
score_sigma,
crate::ConvergenceOptions::default(),
arena,
)
.evidence .evidence
.ln() .ln(),
}
} }
}; };
+1
View File
@@ -15,6 +15,7 @@ fn add_events_bulk_via_iter() {
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: 30,
epsilon: 1e-6, epsilon: 1e-6,
alpha: 1.0,
}) })
.build(); .build();
+1
View File
@@ -16,6 +16,7 @@ fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: 30,
epsilon: 1e-6, epsilon: 1e-6,
alpha: 1.0,
}) })
.build(); .build();
+1
View File
@@ -10,6 +10,7 @@ fn record_winner_builds_history() {
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: 30,
epsilon: 1e-6, epsilon: 1e-6,
alpha: 1.0,
}) })
.build(); .build();