20 Commits

Author SHA1 Message Date
logaritmisk 2b5d3b1687 chore: Release trueskill-tt version 0.1.2 2026-06-12 22:24:11 +02:00
logaritmisk e4ff46f45c fix(gaussian): treat non-positive precision as improper in mu()/sigma()
EP message cancellation can leave a Gaussian's precision (pi) a tiny
negative value — round-off of exactly zero. mu()/sigma() only special-cased
pi == 0, so sigma() computed 1/sqrt(pi) = NaN for pi < 0. That NaN flowed
through the moment-space Sub in the game diff-chain and poisoned every skill
in the slice once it grew past ~75 competitors, making converge() return
all-NaN on real-scale histories (regression vs 0.1.0, which stored sigma
directly). Guard pi <= 0.0 in both accessors (improper Gaussian: mu 0,
sigma infinite), matching the existing pi == 0 handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:27:47 +02:00
logaritmisk 7742b2b891 test(history): end-to-end per-event score_sigma override tests
Three integration tests on a 2-team scored event:
- inheritance: Outcome::scores(...) with no override produces
  bit-equal posteriors to the same outcome wrapped in
  scores_with_sigma(scores, history.score_sigma)
- override-supersedes-default: scores_with_sigma(scores, X) with
  history score_sigma(Y) produces bit-equal posteriors to
  scores(...) with history score_sigma(X), AND differs measurably
  from scores(...) with history score_sigma(Y)
- builder threading: EventBuilder::scores_with_sigma reaches the
  ingest path identically to the Outcome constructor
2026-05-08 21:30:30 +02:00
logaritmisk 52482eea5f feat(event_builder): expose scores_with_sigma fluent method
Adds EventBuilder::scores_with_sigma, the fluent-builder ergonomic
mirror of Outcome::scores_with_sigma. Lets users write
h.event(t).team(...).team(...).scores_with_sigma([..], sigma).commit()
to set a per-event score_sigma override.
2026-05-08 21:28:08 +02:00
logaritmisk b46e7f068d feat(outcome): per-event score_sigma override on Outcome::Scored
Outcome::Scored shape changes from tuple to struct:
{ scores, sigma: Option<f64> }. New constructor scores_with_sigma
sets sigma=Some(s) and debug-asserts s > 0.0; existing scores(I)
constructor keeps its signature and builds with sigma=None internally.
team_count, as_scores, as_ranks accessor pattern matches updated.

History::add_events resolves sigma.unwrap_or(self.score_sigma) at the
ingest arm, so downstream EventKind::Scored stays a plain f64 and
TimeSlice / run_chain need zero changes.

Breaking change to the public Outcome::Scored variant shape
(acceptable in 0.1.x). Bit-equal for callers using the no-override
path because the resolution falls through to self.score_sigma exactly
as before.
2026-05-08 21:27:09 +02:00
logaritmisk d1d6b5136c docs: implementation plan for per-event score_sigma override
Three tasks: foundational Outcome variant change + ingest resolution
(atomic, every commit builds), additive EventBuilder fluent method,
and three end-to-end integration tests covering inheritance,
override-supersedes-default, and builder threading.
2026-05-08 16:12:33 +02:00
logaritmisk 46625d247a docs: spec for per-event score_sigma override
Outcome::Scored becomes a struct variant with an Option<f64> sigma
field. None inherits HistoryBuilder::score_sigma; Some(s) overrides
per event. Resolved at ingest time so EventKind::Scored stays a plain
f64 and TimeSlice/run_chain need zero changes. New constructors
Outcome::scores_with_sigma and EventBuilder::scores_with_sigma cover
the override path; existing scores(..) keeps its signature with
sigma=None internally.

Breaking change to Outcome::Scored variant shape (tuple → struct);
acceptable in 0.1.x. Closes the last item from the T4-MarginFactor
deferred wishlist.
2026-05-08 16:05:27 +02:00
logaritmisk 68be7ab5b7 test(history): end-to-end ConvergenceOptions propagation tests
Two integration tests on a 4-team ranked event:
- max_iter=1 set on HistoryBuilder produces measurably different
  posteriors than default, proving the inner loop honors the
  propagated max_iter
- alpha=0.5 with extra iterations reaches the same fixed point as
  alpha=1.0, proving damping doesn't break correctness on the History
  path

Also updates the alpha doc comment to clarify it applies only to the
within-game EP loop, not the outer cross-history sweep.
2026-05-08 15:34:58 +02:00
logaritmisk 824b7f50b0 feat(time_slice): inference callsites read self.convergence
The three Game::*_with_arena callsites in time_slice.rs (in
TimeSlice::iteration's sequential branch, TimeSlice::log_evidence's
run_event closure, and Event::iteration_direct via parameter) now use
the propagated ConvergenceOptions instead of hardcoded ::default().
sweep_color_groups (both rayon and non-rayon paths) forwards
self.convergence into Event::iteration_direct.

Damped EP (alpha < 1.0) and custom max_iter / epsilon set on
HistoryBuilder::convergence(opts) now actually reach the within-game
inference loop. Bit-equal for users on default options.

Removes the temporary #[allow(dead_code)] on TimeSlice::convergence
that was added in the prior commit.
2026-05-08 15:32:25 +02:00
logaritmisk 872f91797d refactor(time_slice): add convergence field, rename iterate_to_convergence
TimeSlice<T> gains a pub(crate) convergence: ConvergenceOptions field
set at construction. TimeSlice::new now takes it as a third parameter
(breaking change to the pub constructor, acceptable in 0.1.x).
History::add_events_with_prior passes self.convergence so the propagated
value reaches every TimeSlice. The pre-existing convergence-the-method
is renamed to iterate_to_convergence to disambiguate from the new
convergence-the-field.

The field is wired but not yet read by inference -- the three
Game::*_with_arena callsites in time_slice.rs still hardcode
ConvergenceOptions::default(). Task 2 changes that. Bit-equal because
the propagated value equals the hardcoded value end-to-end.

Also updated benches/batch.rs which has a fourth TimeSlice::new
callsite (not enumerated in the plan -- only src/ files were).
2026-05-08 15:29:39 +02:00
logaritmisk 6e453b6845 docs: implementation plan for History → TimeSlice plumbing
Three tasks: TimeSlice gains convergence field + method rename +
History passes self.convergence (atomic), three inference callsites
read self.convergence, and end-to-end tests + alpha doc-comment update.
2026-05-08 15:26:38 +02:00
logaritmisk 965ea7ed3c docs: spec for History → TimeSlice ConvergenceOptions plumbing
Closes the gap between HistoryBuilder::convergence(opts) and the
within-game inference loop. TimeSlice gains a convergence field;
History passes self.convergence at construction; the three
Game::*_with_arena callsites in time_slice.rs read it. Also renames
TimeSlice::convergence the method (now iterate_to_convergence) to
disambiguate from the new field.

Pure plumbing — no new public API, no behavioral change for users on
default options. Makes Damped EP reachable through the History path.
2026-05-08 15:23:11 +02:00
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
24 changed files with 3985 additions and 79 deletions
+46
View File
@@ -2,8 +2,54 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 0.1.2 - 2026-06-12
### Bug Fixes
- fix: release generated CHANGELOG at the wrong location
- fix(gaussian): treat non-positive precision as improper in mu()/sigma()
### Documentation
- docs: spec for post-T4-MarginFactor tech debt cleanup
- docs: implementation plan for post-T4-MarginFactor tech debt cleanup
- docs: fix stale numerics in t4-margin-factor plan
- docs: spec for game-local Damped EP
- docs: implementation plan for game-local Damped EP
- docs: spec for History → TimeSlice ConvergenceOptions plumbing
- docs: implementation plan for History → TimeSlice plumbing
- docs: spec for per-event score_sigma override
- docs: implementation plan for per-event score_sigma override
### Features
- feat(gaussian): add damp_natural helper for EP damping
- feat(convergence): add ConvergenceOptions::alpha damping field
- feat(factor): add TruncFactor::propagate_with_alpha for EP damping
- feat(factor): add MarginFactor::propagate_with_alpha for EP damping
- feat(game): plumb ConvergenceOptions through to run_chain
- feat(time_slice): inference callsites read self.convergence
- feat(outcome): per-event score_sigma override on Outcome::Scored
- feat(event_builder): expose scores_with_sigma fluent method
### Refactor
- refactor: dedupe Game::likelihoods and likelihoods_scored via run_chain
- refactor: make BuiltinFactor::log_evidence match exhaustive
- refactor(time_slice): add convergence field, rename iterate_to_convergence
### Testing
- test(game): integration tests for ConvergenceOptions behavior
- test(history): end-to-end ConvergenceOptions propagation tests
- test(history): end-to-end per-event score_sigma override tests
## 0.1.1 - 2026-04-27 ## 0.1.1 - 2026-04-27
### Miscellaneous Tasks
- chore: Release trueskill-tt version 0.1.1
### Other (unconventional) ### Other (unconventional)
- T0 + T1 + T2: engine redesign through new API surface (#1) - T0 + T1 + T2: engine redesign through new API surface (#1)
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "trueskill-tt" name = "trueskill-tt"
version = "0.1.1" version = "0.1.2"
edition = "2024" edition = "2024"
[lib] [lib]
+3 -3
View File
@@ -1,7 +1,7 @@
use criterion::{Criterion, criterion_group, criterion_main}; use criterion::{Criterion, criterion_group, criterion_main};
use trueskill_tt::{ use trueskill_tt::{
BETA, Competitor, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA, TimeSlice, BETA, Competitor, ConvergenceOptions, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA,
drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore, TimeSlice, drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
}; };
fn criterion_benchmark(criterion: &mut Criterion) { fn criterion_benchmark(criterion: &mut Criterion) {
@@ -35,7 +35,7 @@ fn criterion_benchmark(criterion: &mut Criterion) {
let kinds = vec![EventKind::Ranked; composition.len()]; let kinds = vec![EventKind::Ranked; composition.len()];
let mut time_slice = TimeSlice::new(1, P_DRAW); let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
time_slice.add_events(composition, results, weights, kinds, &agents); time_slice.add_events(composition, results, weights, kinds, &agents);
criterion.bench_function("Batch::iteration", |b| { criterion.bench_function("Batch::iteration", |b| {
+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();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,593 @@
# History → TimeSlice ConvergenceOptions Plumbing 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:** Thread `ConvergenceOptions` from `History` through `TimeSlice` to the three `Game::*_with_arena` callsites in `time_slice.rs`, so users who set `HistoryBuilder::convergence(opts)` actually get those options applied to within-game inference (including Damped's `alpha`).
**Architecture:** `TimeSlice<T>` gains a `convergence: ConvergenceOptions` field set at construction. `History::add_events_with_prior` passes `self.convergence`. The three `Game::*_with_arena` callsites in `time_slice.rs` swap their hardcoded `ConvergenceOptions::default()` for the propagated value. The pre-existing `TimeSlice::convergence` method is renamed to `iterate_to_convergence` to disambiguate from the new field. No new public API on `History` or `HistoryBuilder``convergence(opts)` already exists and works.
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test --lib`.
---
## Spec reference
`docs/superpowers/specs/2026-05-08-history-convergence-plumbing-design.md`
## Pre-flight context for the implementer
- `HistoryBuilder::convergence(opts)` already exists at `src/history.rs:91`. `History` already stores `convergence: ConvergenceOptions` at `src/history.rs:166`. `History::converge()` already reads `self.convergence.{epsilon, max_iter}` at `src/history.rs:437-447` for the OUTER cross-history loop.
- `TimeSlice<T>` is at `src/time_slice.rs:172-180`. Currently has fields `events`, `skills`, `time`, `p_draw`, `arena`, `color_groups`. No convergence field yet.
- `TimeSlice::new(time, p_draw)` at `src/time_slice.rs:183-192` is `pub`. Five test callsites use it with `(0i64, 0.0)`. One production callsite in `History::add_events_with_prior` at `src/history.rs:597` uses `(t, self.p_draw)`.
- Three callsites in `time_slice.rs` call `Game::*_with_arena` with hardcoded `crate::ConvergenceOptions::default()`:
- `Event::iteration_direct` at `src/time_slice.rs:131-169` — does NOT have `&self` access to a TimeSlice. Currently takes `(skills, agents, p_draw, arena)`. Needs to gain a `convergence` parameter.
- `TimeSlice::iteration` at `src/time_slice.rs:322-363` — has `&mut self`, so reads `self.convergence` directly.
- `TimeSlice::log_evidence` at `src/time_slice.rs:505-540` — has `&self`, so reads `self.convergence` directly.
- The rayon path in `sweep_color_groups` at `src/time_slice.rs:376-423` uses a `move` closure capturing `p_draw` by value. The same pattern applies to `convergence` (it's `Copy`, so captures cleanly).
- `TimeSlice::convergence` (the **method** at `src/time_slice.rs:447`) shares its name with the new field. Rust technically allows this (different namespaces), but it's a readability hazard — must be renamed. The method is called from 4 test sites in `time_slice.rs` (lines 693, 755, 817, 851). It is NOT called from `history.rs`.
- `ConvergenceOptions` is `Copy + Clone + Debug`. Pass by value everywhere.
## File map
| File | Why touched |
|---|---|
| `src/time_slice.rs` | TimeSlice gains `convergence` field, `new` signature change, rename `convergence` method, three callsites read `self.convergence`, `Event::iteration_direct` gains parameter, rayon closure captures it |
| `src/history.rs` | `add_events_with_prior` passes `self.convergence` to `TimeSlice::new`; two integration tests added; alpha doc-comment update happens in `convergence.rs` not here |
| `src/convergence.rs` | One-sentence addition to `alpha` doc comment clarifying within-game-only scope |
---
### Task 1: TimeSlice gains `convergence` field; signature/rename land atomically
This task does five things atomically — they cannot land separately because intermediate states won't compile:
1. Add `pub(crate) convergence: ConvergenceOptions` field to `TimeSlice<T>`.
2. Change `TimeSlice::new` signature to take `convergence: ConvergenceOptions` as the third parameter.
3. Update the production callsite in `History::add_events_with_prior` (`src/history.rs:597`) to pass `self.convergence`.
4. Update the five test callsites in `src/time_slice.rs` (lines 646, 723, 803, 901 — the four with `TimeSlice::new(0i64, 0.0)`, plus the one inside the test module's `iterate_through_color_groups` test if it exists; locate via `grep -n "TimeSlice::new" src/time_slice.rs`).
5. Rename the existing `pub(crate) fn convergence` method (at `src/time_slice.rs:447`) to `iterate_to_convergence`. Update its 4 in-file call sites.
After this task the convergence field is wired but **unused** by inference (Task 2 makes the three Game callsites read it). All existing tests must pass bit-equal because the propagated value still equals `ConvergenceOptions::default()` end-to-end.
**Files:**
- Modify: `src/time_slice.rs`
- Modify: `src/history.rs:597`
- [ ] **Step 1: Locate all `TimeSlice::new` and `convergence`-method callsites**
Run:
```bash
grep -n "TimeSlice::new\|\.convergence(" src/time_slice.rs src/history.rs
```
Expected: 1 production callsite of `TimeSlice::new` in `history.rs`, 5 test callsites in `time_slice.rs`, and 4 method-style `.convergence(` calls in `time_slice.rs` test module. (No `.convergence(` calls in `history.rs` — those are field accesses.)
Save the line numbers — you'll need them in Step 4 and Step 6.
- [ ] **Step 2: Add the `convergence` field to `TimeSlice<T>`**
In `src/time_slice.rs`, modify the `TimeSlice<T>` struct (currently at `src/time_slice.rs:172-180`):
```rust
#[derive(Debug)]
pub struct TimeSlice<T: Time = i64> {
pub(crate) events: Vec<Event>,
pub(crate) skills: SkillStore,
pub(crate) time: T,
p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
arena: ScratchArena,
pub(crate) color_groups: ColorGroups,
}
```
Code won't compile until Step 3.
- [ ] **Step 3: Change `TimeSlice::new` signature**
In `src/time_slice.rs`, replace the existing `pub fn new` (currently at `src/time_slice.rs:183-192`) with:
```rust
pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
Self {
events: Vec::new(),
skills: SkillStore::new(),
time,
p_draw,
convergence,
arena: ScratchArena::new(),
color_groups: ColorGroups::new(),
}
}
```
- [ ] **Step 4: Update the production callsite in `history.rs`**
In `src/history.rs:597`, replace:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw);
```
with:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
```
- [ ] **Step 5: Update test callsites of `TimeSlice::new`**
Run `cargo build --tests` to surface every remaining compile error. Each error is a `TimeSlice::new(time, p_draw)` callsite missing the third argument. The fix: add `crate::ConvergenceOptions::default(),` (inside `src/time_slice.rs` test modules use the path relative to where `ConvergenceOptions` is in scope — if it's not imported in that test mod, add `use crate::ConvergenceOptions;` at the top of the mod and pass `ConvergenceOptions::default()`).
Example transformation. Before:
```rust
let mut time_slice = TimeSlice::new(0i64, 0.0);
```
After:
```rust
let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
```
Apply to all 5 test callsites identified in Step 1. Repeat `cargo build --tests` until it succeeds.
- [ ] **Step 6: Rename the `convergence` method to `iterate_to_convergence`**
In `src/time_slice.rs`, find the method definition at `src/time_slice.rs:447`:
```rust
pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
```
Rename to:
```rust
pub(crate) fn iterate_to_convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize {
```
Then update the 4 call sites (located in Step 1 — `time_slice.rs:693, 755, 817, 851` or wherever your grep found them). At each site, replace `time_slice.convergence(&agents)` with `time_slice.iterate_to_convergence(&agents)`.
- [ ] **Step 7: Build and run the full test suite**
Run: `cargo build && cargo test --lib`
Expected: all 98 lib tests pass. Bit-equal goldens — the convergence field is wired but the three inference callsites still hardcode `ConvergenceOptions::default()` (Task 2 changes that), and the propagated default equals what was hardcoded before, so behavior is identical.
If any test fails: investigate. The most likely cause is a missed `TimeSlice::new` callsite or a `.convergence(` call site that needs renaming.
- [ ] **Step 8: Run integration tests**
Run: `cargo test`
Expected: all 27 integration tests still pass.
- [ ] **Step 9: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 10: Commit**
```bash
git add src/time_slice.rs src/history.rs
git commit -m "$(cat <<'EOF'
refactor(time_slice): add convergence field, rename iterate_to_convergence
TimeSlice<T> gains a pub(crate) convergence: ConvergenceOptions field
set at construction. TimeSlice::new now takes it as a third parameter
(breaking change to the pub constructor, acceptable in 0.1.x).
History::add_events_with_prior passes self.convergence so the propagated
value reaches every TimeSlice. The pre-existing convergence-the-method
is renamed to iterate_to_convergence to disambiguate from the new
convergence-the-field.
The field is wired but not yet read by inference — the three
Game::*_with_arena callsites in time_slice.rs still hardcode
ConvergenceOptions::default(). Task 2 changes that. Bit-equal because
the propagated value equals the hardcoded value end-to-end.
EOF
)"
```
---
### Task 2: Read `self.convergence` at the three inference callsites
This task switches the three `Game::*_with_arena` callsites in `time_slice.rs` from hardcoded `ConvergenceOptions::default()` to the propagated `self.convergence` (or for `Event::iteration_direct`, a passed-in parameter). After this task, Damped EP set on `HistoryBuilder` actually reaches the within-game loop.
**Files:**
- Modify: `src/time_slice.rs` (only)
- [ ] **Step 1: Add a `convergence` parameter to `Event::iteration_direct`**
In `src/time_slice.rs`, modify the existing `iteration_direct` signature (currently at `src/time_slice.rs:131-137`):
```rust
fn iteration_direct<T: Time, D: Drift<T>>(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<T, D>,
p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena,
) {
```
Inside the body (around `src/time_slice.rs:140-156`), replace both `crate::ConvergenceOptions::default()` arguments with `convergence`:
```rust
let g = match self.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&self.weights,
p_draw,
convergence,
arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&self.weights,
score_sigma,
convergence,
arena,
),
};
```
- [ ] **Step 2: Update the rayon path in `sweep_color_groups` (cfg=rayon)**
In `src/time_slice.rs`, the rayon-feature `sweep_color_groups` (currently at `src/time_slice.rs:376-423`) captures `p_draw` by value into a `move` closure and calls `ev.iteration_direct(skills, agents, p_draw, &mut arena)`. Capture `convergence` the same way and pass it:
Above the rayon `for_each` at the line `let p_draw = self.p_draw;`, add:
```rust
let convergence = self.convergence;
```
Then update the call inside the closure (currently `ev.iteration_direct(skills, agents, p_draw, &mut arena);`):
```rust
ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
```
The `else` branch (sequential fallback) at `src/time_slice.rs:417-421` calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` — also update:
```rust
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
```
(Note: this branch reads `self.convergence` directly because no `move` closure is involved here.)
- [ ] **Step 3: Update the non-rayon path in `sweep_color_groups`**
In `src/time_slice.rs`, the `#[cfg(not(feature = "rayon"))]` `sweep_color_groups` (currently at `src/time_slice.rs:428-444`) calls `ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);` at `src/time_slice.rs:441`. Replace with:
```rust
ev.iteration_direct(&mut self.skills, agents, p_draw, self.convergence, &mut self.arena);
```
- [ ] **Step 4: Update `TimeSlice::iteration`'s sequential branch**
In `src/time_slice.rs`, modify `TimeSlice::iteration` (at `src/time_slice.rs:322-363`). The sequential branch (when `from > 0 || self.color_groups.is_empty()`) has two `Game::*_with_arena` callsites at `src/time_slice.rs:330-346` that hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
```rust
let g = match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
self.convergence,
&mut self.arena,
),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
&mut self.arena,
),
};
```
- [ ] **Step 5: Update `TimeSlice::log_evidence`**
In `src/time_slice.rs`, modify `TimeSlice::log_evidence` (at `src/time_slice.rs:505-540`). The two `Game::*_with_arena` callsites in the inner `run_event` closure at `src/time_slice.rs:519-538` hardcode `crate::ConvergenceOptions::default()`. Replace both with `self.convergence`:
```rust
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
let teams = event.within_priors(online, forward, &self.skills, agents);
let result = event.outputs();
match event.kind {
EventKind::Ranked => Game::ranked_with_arena(
teams,
&result,
&event.weights,
self.p_draw,
self.convergence,
arena,
)
.evidence
.ln(),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
arena,
)
.evidence
.ln(),
}
};
```
(`self.convergence` is `Copy`, so the closure captures it by value naturally without needing a `let` binding outside.)
- [ ] **Step 6: Build and run the full test suite — bit-equal regression net**
Run: `cargo build && cargo test --lib`
Expected: all 98 lib tests still pass. Bit-equal goldens — every existing test uses `History::default()` or `HistoryBuilder::default()` (which sets `convergence = ConvergenceOptions::default()`), so the propagated value equals what the hardcoded default was. No test exercises a non-default convergence through History today, so no behavior changes.
If any test fails: investigate. The most likely cause is a stale `crate::ConvergenceOptions::default()` call missed in steps 1-5 — re-grep with `grep -n "ConvergenceOptions::default" src/time_slice.rs` to find any remaining hardcoded sites.
- [ ] **Step 7: Run integration tests**
Run: `cargo test`
Expected: all 27 integration tests still pass.
- [ ] **Step 8: Confirm no `crate::ConvergenceOptions::default()` remains in time_slice.rs**
Run: `grep -n "ConvergenceOptions::default" src/time_slice.rs`
Expected: only test-mod hits (in `TimeSlice::new(0i64, 0.0, ConvergenceOptions::default())` callsites from Task 1 step 5). NO production-code hits in `Event::iteration_direct`, `sweep_color_groups`, `TimeSlice::iteration`, or `TimeSlice::log_evidence`.
- [ ] **Step 9: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 10: Commit**
```bash
git add src/time_slice.rs
git commit -m "$(cat <<'EOF'
feat(time_slice): inference callsites read self.convergence
The three Game::*_with_arena callsites in time_slice.rs (in
TimeSlice::iteration's sequential branch, TimeSlice::log_evidence's
run_event closure, and Event::iteration_direct via parameter) now use
the propagated ConvergenceOptions instead of hardcoded ::default().
sweep_color_groups (both rayon and non-rayon paths) forwards
self.convergence into Event::iteration_direct.
Damped EP (alpha < 1.0) and custom max_iter / epsilon set on
HistoryBuilder::convergence(opts) now actually reach the within-game
inference loop. Bit-equal for users on default options.
EOF
)"
```
---
### Task 3: Doc-comment update + end-to-end integration tests
**Files:**
- Modify: `src/convergence.rs` (alpha doc comment)
- Modify: `src/history.rs` (two integration tests in the existing `#[cfg(test)] mod tests` block)
- [ ] **Step 1: Update `ConvergenceOptions::alpha` doc comment**
In `src/convergence.rs`, find the existing doc comment on the `alpha` field. Replace it with:
```rust
/// EP damping factor in natural-parameter space: each per-factor
/// update inside a single game 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]`.
///
/// Applies only to the within-game EP loop (`run_chain`). The outer
/// `History::converge` cross-history sweep is undamped regardless of
/// this value — cross-slice damping is a different concept and not
/// in scope.
pub alpha: f64,
```
- [ ] **Step 2: Locate the `#[cfg(test)] mod tests` block in `src/history.rs`**
Run: `grep -n "#\[cfg(test)\]" src/history.rs`
Identify the test module (there should be one near the bottom of the file). Read the imports at the top of that module so the new tests can reuse the existing test helpers and scope.
- [ ] **Step 3: Write the failing tests**
Add the following two tests at the end of the test module in `src/history.rs` (just before the module's closing `}`):
```rust
#[test]
fn history_propagates_convergence_to_inner_run_chain() {
use crate::ConvergenceOptions;
// 4-team ranked game; each event needs more than one inner EP iter
// to fully converge.
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
crate::observer::NullObserver, &'static str>| {
for &name in &["a", "b", "c", "d"] {
h.new_agent(name);
}
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.commit()
.unwrap();
};
let mut h_capped = crate::History::builder()
.convergence(ConvergenceOptions {
max_iter: 1,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_capped);
h_capped.converge().unwrap();
let mut h_full = crate::History::builder().build();
events_for(&mut h_full);
h_full.converge().unwrap();
let curves_capped = h_capped.learning_curves();
let curves_full = h_full.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, capped_pts) in curves_capped.iter() {
let full_pts = curves_full.get(key).expect("agent missing in full");
for (capped, full) in capped_pts.iter().zip(full_pts.iter()) {
max_diff = max_diff.max((capped.1.mu() - full.1.mu()).abs());
max_diff = max_diff.max((capped.1.sigma() - full.1.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"max_iter=1 inner loop should differ from default; max_diff={max_diff}"
);
}
#[test]
fn history_with_damping_reaches_same_fixed_point_as_undamped() {
use crate::ConvergenceOptions;
let events_for = |h: &mut crate::History<i64, crate::drift::ConstantDrift,
crate::observer::NullObserver, &'static str>| {
for &name in &["a", "b", "c", "d"] {
h.new_agent(name);
}
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.commit()
.unwrap();
};
let mut h_undamped = crate::History::builder().build();
events_for(&mut h_undamped);
h_undamped.converge().unwrap();
let mut h_damped = crate::History::builder()
.convergence(ConvergenceOptions {
alpha: 0.5,
max_iter: 200,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_damped);
h_damped.converge().unwrap();
let curves_u = h_undamped.learning_curves();
let curves_d = h_damped.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, u_pts) in curves_u.iter() {
let d_pts = curves_d.get(key).expect("agent missing in damped");
for (u, d) in u_pts.iter().zip(d_pts.iter()) {
max_diff = max_diff.max((u.1.mu() - d.1.mu()).abs());
max_diff = max_diff.max((u.1.sigma() - d.1.sigma()).abs());
}
}
assert!(
max_diff < 1e-3,
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
);
}
```
If the import or method names (e.g. `History::builder()`, `event(...).team(...).commit()`, `learning_curves()`, `new_agent(...)`) don't match what's available in the test module, look at neighboring tests for the exact builder/event-construction pattern in current use and mirror it. The structure (build two Histories, add identical events, compare curves) is the contract; the surface syntax must follow what already works in this test file.
- [ ] **Step 4: Run the new tests**
Run: `cargo test --lib history_propagates_convergence_to_inner_run_chain history_with_damping_reaches_same_fixed_point_as_undamped`
Expected: 2 passed.
**Fallback if Test 1 fails** (`max_iter=1` produces the same posteriors as default — meaning the inner loop converges in one iteration on this graph): replace `max_iter: 1` with `max_iter: 0`. With `max_iter = 0` the inner loop body runs zero times, guaranteeing different posteriors than convergence.
**Fallback if Test 2 fails** (`max_diff` exceeds `1e-3`): raise `max_iter: 200` to `max_iter: 500`. Heavier damping needs more iterations to reach the same fixed point.
If neither fallback works, STOP and report BLOCKED with the actual `max_diff` and the iteration counts tried.
- [ ] **Step 5: Run the full test suite**
Run: `cargo test --lib && cargo test`
Expected: lib count = 100 (was 98), integration count = 27 (unchanged), all passing.
- [ ] **Step 6: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 7: Commit**
```bash
git add src/convergence.rs src/history.rs
git commit -m "$(cat <<'EOF'
test(history): end-to-end ConvergenceOptions propagation tests
Two integration tests on a 4-team ranked event:
- max_iter=1 set on HistoryBuilder produces measurably different
posteriors than default, proving the inner loop honors the
propagated max_iter
- alpha=0.5 with extra iterations reaches the same fixed point as
alpha=1.0, proving damping doesn't break correctness on the History
path
Also updates the alpha doc comment to clarify it applies only to the
within-game EP loop, not the outer cross-history sweep.
EOF
)"
```
---
## Self-review (writer's note)
**Spec coverage:**
- Spec § "What ships" item 1 (TimeSlice convergence field) → Task 1 step 2 ✓
- Spec § "What ships" item 2 (TimeSlice::new signature) → Task 1 step 3 ✓
- Spec § "What ships" item 3 (History passes self.convergence) → Task 1 step 4 ✓
- Spec § "What ships" item 4 (Event::iteration_direct gains parameter) → Task 2 step 1 ✓
- Spec § "What ships" item 4 (callers pass self.convergence) → Task 2 steps 2, 3 ✓
- Spec § "What ships" item 5 (TimeSlice::convergence-method reads field) → Task 2 step 4 ✓
- Spec § "What ships" item 6 (log_evidence reads field) → Task 2 step 5 ✓
- Spec § "What ships" item 7 (test callsite updates) → Task 1 step 5 ✓
- Spec § "Design" rename method → Task 1 step 6 ✓
- Spec § "Risks" alpha doc-comment update → Task 3 step 1 ✓
- Spec § "Testing strategy" §1 (regression net) → Tasks 1 step 7, 2 step 6, 3 step 5 ✓
- Spec § "Testing strategy" §2 (history_propagates_convergence) → Task 3 step 3 test 1 ✓
- Spec § "Testing strategy" §2 (history_with_damping_reaches_same_fixed_point) → Task 3 step 3 test 2 ✓
**Out-of-scope items correctly absent:** No new `History`/`HistoryBuilder` methods, no `ConvergenceOptions` split, no `Damped` Schedule impl, no nat-param convergence switch.
**Type / signature consistency:**
- `TimeSlice::new(time, p_draw, convergence: ConvergenceOptions)` — Task 1 step 3 (def) and Task 1 step 4-5 (call sites) match ✓
- `iteration_direct(skills, agents, p_draw, convergence, arena)` — Task 2 step 1 (def) and steps 2, 3 (call sites) match ✓
- `iterate_to_convergence` — Task 1 step 6 ✓
- All `self.convergence` reads are field accesses, not method calls (the rename in Task 1 step 6 prevents ambiguity) ✓
**Two tasks (1 and 2) split rationale:** Task 1 wires the field but the inference path still uses hardcoded defaults (no behavioral change). Task 2 makes the field actually drive inference (behavioral change for non-default users). Each task is independently committable and the test suite is bit-equal at every checkpoint.
**No placeholders detected.**
@@ -0,0 +1,540 @@
# Per-Event `score_sigma` Override 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:** Let users specify a per-event score-sigma override on `Outcome::Scored`, defaulting to `HistoryBuilder::score_sigma` when not set.
**Architecture:** `Outcome::Scored` becomes a struct variant with an `Option<f64>` `sigma` field. `History::add_events` resolves `sigma.unwrap_or(self.score_sigma)` at ingest time, so downstream `EventKind::Scored.score_sigma` stays a plain `f64` and `TimeSlice` / `run_chain` need zero changes. Two new constructors (`Outcome::scores_with_sigma` and `EventBuilder::scores_with_sigma`) cover the override path; existing `scores(...)` keeps its signature.
**Tech Stack:** Rust 2024, `cargo +nightly fmt`, `cargo clippy`, `cargo test`.
---
## Spec reference
`docs/superpowers/specs/2026-05-08-per-event-score-sigma-design.md`
## File map
| File | Why touched |
|---|---|
| `src/outcome.rs` | `Outcome::Scored` variant becomes a struct; pattern matches in `team_count`, `as_scores`, `as_ranks`; new `scores_with_sigma` constructor; existing `scores` constructor body adapts |
| `src/history.rs` | The single ingest pattern match at `:735` resolves `sigma.unwrap_or(self.score_sigma)`; three new end-to-end tests |
| `src/event_builder.rs` | New `scores_with_sigma` builder method |
## Pre-flight context for the implementer
- `Outcome` is `pub`. Currently a tuple-variant enum at `src/outcome.rs:18-21`. Changing `Scored(SmallVec)``Scored { scores, sigma }` is a breaking change to a public variant shape, acceptable in 0.1.x.
- Pattern-match callsite inventory across the workspace (verified by grep): only ONE site destructures the variant — `src/history.rs:735` (`crate::Outcome::Scored(scores) => { ... }`). Every other reference is either a constructor call (`Outcome::scores(...)`) or a string literal in a doc/error message. The constructors keep their existing signatures, so callsites don't need updating.
- `Outcome::scores(I)` constructor at `src/outcome.rs:44`: keep the signature `pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self`. Only the body changes (it now builds `Self::Scored { scores: ..., sigma: None }`).
- `as_scores`, `as_ranks`, `team_count` accessors at `src/outcome.rs:48-67`: their public signatures stay the same. Internal pattern matches adapt mechanically.
- `EventBuilder::scores(I)` at `src/event_builder.rs:79-82`: keep unchanged. The new `scores_with_sigma(I, f64)` lives next to it.
- `History::score_sigma` at `src/history.rs:165`: still the history-wide default. `HistoryBuilder::score_sigma(s)` builder method at `src/history.rs:82-89` stays as-is.
- `EventKind::Scored { score_sigma: f64 }` at `src/time_slice.rs:51`: already per-event-shaped. Don't touch.
- Test baseline: 100 lib + 27 integration tests, all passing.
---
### Task 1: `Outcome::Scored` becomes a struct variant + constructors
This is the foundational shape change. After this task: the new variant compiles, both `scores` and `scores_with_sigma` work on `Outcome` directly, but `History::add_events` (the only consumer that destructures the variant) hasn't yet been updated — Task 2 handles that.
**Files:**
- Modify: `src/outcome.rs` (variant shape, three pattern-match arms, two existing tests, three new tests, two constructors)
- [ ] **Step 1: Write failing tests for the new constructor**
In `src/outcome.rs`, inside the existing `#[cfg(test)] mod tests` block, add at the end:
```rust
#[test]
fn scores_with_sigma_round_trips() {
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5);
assert_eq!(o.team_count(), 2);
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
}
#[test]
fn scores_constructor_leaves_sigma_unset() {
// After the variant change, the public Outcome::scores constructor
// must build with sigma: None. We assert this indirectly via a match
// on the variant.
let o = Outcome::scores([3.0, 1.0]);
match o {
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
#[test]
fn scores_with_sigma_sets_sigma_some() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
match o {
Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
#[test]
#[should_panic(expected = "score_sigma must be > 0.0")]
fn scores_with_sigma_rejects_zero() {
let _ = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
}
```
- [ ] **Step 2: Run the new tests to verify they fail**
Run: `cargo test --lib outcome::tests`
Expected: 4 errors. The first three fail to compile (no `scores_with_sigma` function; pattern destructure on `Scored { ... }` doesn't match the current tuple variant). The last fails because `scores_with_sigma` doesn't exist.
- [ ] **Step 3: Change the variant shape and update the constructor + accessors**
In `src/outcome.rs`, replace the entire `Outcome` enum and `impl Outcome` block (currently `src/outcome.rs:16-68`) with:
```rust
/// Final outcome of a match.
///
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
/// teams. `ranks.len()` must equal the number of teams in the event.
///
/// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs
/// feed observed margins to `MarginFactor`. `scores.len()` must equal the
/// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
/// when `Some`; `None` inherits the history default.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Outcome {
Ranked(SmallVec<[u32; 4]>),
Scored {
scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
sigma: Option<f64>,
},
}
impl Outcome {
/// `n`-team outcome where team `winner` won and everyone else tied for last.
///
/// Panics if `winner >= n`.
pub fn winner(winner: u32, n: u32) -> Self {
assert!(winner < n, "winner index {winner} out of range 0..{n}");
let ranks: SmallVec<[u32; 4]> = (0..n).map(|i| if i == winner { 0 } else { 1 }).collect();
Self::Ranked(ranks)
}
/// All `n` teams tied.
pub fn draw(n: u32) -> Self {
Self::Ranked(SmallVec::from_vec(vec![0; n as usize]))
}
/// Explicit per-team ranking.
pub fn ranking<I: IntoIterator<Item = u32>>(ranks: I) -> Self {
Self::Ranked(ranks.into_iter().collect())
}
/// Explicit per-team continuous scores; higher = better.
/// Inherits `HistoryBuilder::score_sigma` for the noise model.
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored {
scores: scores.into_iter().collect(),
sigma: None,
}
}
/// Explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` must be `> 0.0`; debug-asserts otherwise.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self {
debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})");
Self::Scored {
scores: scores.into_iter().collect(),
sigma: Some(sigma),
}
}
pub fn team_count(&self) -> usize {
match self {
Self::Ranked(r) => r.len(),
Self::Scored { scores, .. } => scores.len(),
}
}
pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
match self {
Self::Ranked(r) => Some(r),
Self::Scored { .. } => None,
}
}
pub(crate) fn as_scores(&self) -> Option<&[f64]> {
match self {
Self::Scored { scores, .. } => Some(scores),
Self::Ranked(_) => None,
}
}
}
```
- [ ] **Step 4: Run the new tests**
Run: `cargo test --lib outcome::tests`
Expected: all outcome tests pass (the 6 pre-existing tests + 4 new = 10 total in the outcome tests module).
If any pre-existing test fails, the issue is in this task — not Task 2. Most likely cause: a pattern-match arm in the rewritten `impl Outcome` block doesn't compile. Re-check the struct-variant destructure syntax (`Self::Scored { scores, .. }` for read-only access; `Self::Scored { scores, sigma }` when both fields are needed).
- [ ] **Step 5: Update `History::add_events` ingest arm to destructure the new variant**
The variant change from Step 3 breaks the existing `Outcome::Scored(scores)` pattern match in `src/history.rs:735`. Fix it now (in the same commit) — the codebase must build at every commit boundary.
In `src/history.rs`, find the `crate::Outcome::Scored(scores) => { ... }` arm (currently at `src/history.rs:735-740`). Replace with:
```rust
crate::Outcome::Scored { scores, sigma } => {
let resolved = sigma.unwrap_or(self.score_sigma);
debug_assert!(
resolved > 0.0,
"resolved score_sigma must be > 0.0 (got {resolved})"
);
kinds.push(EventKind::Scored {
score_sigma: resolved,
});
scores.to_vec()
}
```
The surrounding `match &ev.outcome { ... }` and the surrounding flow (the `ranks` arm above, the `results.push(event_result);` below) stay unchanged.
- [ ] **Step 6: Run the full library test suite — bit-equal regression net**
Run: `cargo build && cargo test --lib && cargo test`
Expected: clean build. All 100 lib + 27 integration tests pass. Bit-equal goldens — every existing scored-event constructor uses the no-override path (`Outcome::scores(...)` or `EventBuilder::scores(...)`), which now resolves to `sigma: None → resolved = self.score_sigma`, exactly equal to the previous behavior.
If unexpected additional compile errors surface (any site pattern-matching `Outcome::Scored(...)` outside the 735 arm), STOP and report — the plan's inventory is wrong, surface that as a finding before continuing.
If any existing test fails: investigate. Most likely cause is a typo in the new pattern arms (Step 3) or the resolution rule (Step 5). The override path isn't exercised yet by any existing test, so the only thing that can break is the inheritance path.
- [ ] **Step 7: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 8: Commit**
```bash
git add src/outcome.rs src/history.rs
git commit -m "$(cat <<'EOF'
feat(outcome): per-event score_sigma override on Outcome::Scored
Outcome::Scored shape changes from tuple to struct:
{ scores, sigma: Option<f64> }. New constructor scores_with_sigma
sets sigma=Some(s) and debug-asserts s > 0.0; existing scores(I)
constructor keeps its signature and builds with sigma=None internally.
team_count, as_scores, as_ranks accessor pattern matches updated.
History::add_events resolves sigma.unwrap_or(self.score_sigma) at the
ingest arm, so downstream EventKind::Scored stays a plain f64 and
TimeSlice / run_chain need zero changes.
Breaking change to the public Outcome::Scored variant shape
(acceptable in 0.1.x). Bit-equal for callers using the no-override
path because the resolution falls through to self.score_sigma exactly
as before.
EOF
)"
```
---
### Task 2: `EventBuilder::scores_with_sigma` builder method
The override path is fully wired by Task 1, but it's only reachable via the `Outcome::scores_with_sigma` constructor (passed into `History::add_events` directly). The fluent-builder ergonomic — `h.event(t).team(...).scores_with_sigma(scores, sigma).commit()` — needs one new method on `EventBuilder`.
**Files:**
- Modify: `src/event_builder.rs` (new builder method)
- [ ] **Step 1: Add the EventBuilder method**
In `src/event_builder.rs`, find the existing `scores` method (currently at `src/event_builder.rs:79-82`). Immediately below it (still inside `impl<'h, T, D, O, K> EventBuilder<...>`), add:
```rust
/// Set explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
/// Must be `> 0.0`; debug-asserts otherwise via `Outcome::scores_with_sigma`.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
self
}
```
- [ ] **Step 2: Build and run the test suite**
Run: `cargo build && cargo test --lib && cargo test`
Expected: clean build, all 100 lib + 27 integration tests pass. The new method is additive — no behavior changes for existing tests.
- [ ] **Step 3: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 4: Commit**
```bash
git add src/event_builder.rs
git commit -m "$(cat <<'EOF'
feat(event_builder): expose scores_with_sigma fluent method
Adds EventBuilder::scores_with_sigma, the fluent-builder ergonomic
mirror of Outcome::scores_with_sigma. Lets users write
h.event(t).team(...).team(...).scores_with_sigma([..], sigma).commit()
to set a per-event score_sigma override.
EOF
)"
```
---
### Task 3: End-to-end integration tests
**Files:**
- Modify: `src/history.rs` (three new tests in the existing `#[cfg(test)] mod tests` block at the bottom)
- [ ] **Step 1: Locate the test module**
Run: `grep -n "^#\[cfg(test)\]" src/history.rs`
Identify the test module (there should be one near the bottom of the file). Read its imports and look at neighboring tests to see the existing builder/event-construction pattern in current use. Mirror that pattern in the new tests below — the surface syntax (`History::builder()`, `event(t).team(...)`, `learning_curves()`, etc.) must match what already works in this file.
- [ ] **Step 2: Write the failing tests**
Add the following three tests at the end of the existing `#[cfg(test)] mod tests` block in `src/history.rs` (just before the module's closing `}`):
```rust
#[test]
fn outcome_scores_default_sigma_uses_history_default() {
use crate::Outcome;
// Path A: explicit sigma=0.5 via override.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], 0.5),
}])
.unwrap();
h_a.converge().unwrap();
// Path B: history-wide default 0.5, no per-event override.
let mut h_b = crate::History::builder().score_sigma(0.5).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
h_b.converge().unwrap();
// Inheritance: posteriors must be bit-equal.
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("agent missing in path B");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
}
}
}
#[test]
fn outcome_scores_with_sigma_overrides_history_default() {
use crate::Outcome;
// Path A: history-wide default 0.5, per-event override 2.0.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
}])
.unwrap();
h_a.converge().unwrap();
// Path B: history-wide default 2.0, no per-event override.
let mut h_b = crate::History::builder().score_sigma(2.0).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
h_b.converge().unwrap();
// Override == default-set-to-the-override-value: bit-equal.
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("agent missing in path B");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
}
}
// Path C: history-wide default 0.5, no override. Different sigma → different posteriors.
let mut h_c = crate::History::builder().score_sigma(0.5).build();
h_c.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
h_c.converge().unwrap();
let curves_c = h_c.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, a_pts) in curves_a.iter() {
let c_pts = curves_c.get(key).expect("agent missing in path C");
for (a, c) in a_pts.iter().zip(c_pts.iter()) {
max_diff = max_diff.max((a.1.mu() - c.1.mu()).abs());
max_diff = max_diff.max((a.1.sigma() - c.1.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"override should produce different posteriors from inherited default; max_diff={max_diff}"
);
}
#[test]
fn event_builder_scores_with_sigma_threading() {
use crate::Outcome;
// Path A: builder fluent API with sigma override.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.event(0_i64)
.team(["a"])
.team(["b"])
.scores_with_sigma([3.0, 1.0], 2.0)
.commit()
.unwrap();
h_a.converge().unwrap();
// Path B: same outcome via the explicit Outcome constructor.
let mut h_b = crate::History::builder().score_sigma(0.5).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
}])
.unwrap();
h_b.converge().unwrap();
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("agent missing");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
}
}
}
```
If the surface API (e.g. `History::add_events`, `Event { time, teams, outcome }`, `Team::with_members`, `Member::new`, `event(...).team(...).commit()`, `learning_curves()`) doesn't exactly match what's available in the test module, look at neighboring tests for the patterns currently in use and adjust. The CONTRACT is: build two Histories that should produce identical posteriors, run them, compare. The surface syntax must follow what compiles in this file.
- [ ] **Step 3: Run the new tests**
Run: `cargo test --lib outcome_scores_default_sigma_uses_history_default outcome_scores_with_sigma_overrides_history_default event_builder_scores_with_sigma_threading`
Expected: 3 passed.
**Fallback if Test 2's `max_diff > 1e-6` fails** (sigma=0.5 vs sigma=2.0 produces nearly identical posteriors — unlikely on a single 2-team scored event, but possible if the priors dominate): use a larger gap, e.g. `Outcome::scores_with_sigma([3.0, 1.0], 5.0)` vs `Outcome::scores([3.0, 1.0])` with `score_sigma(0.5)`. The point is to prove the resolution path actually engages — any sigma gap that produces a measurable posterior difference is fine.
- [ ] **Step 4: Run the full test suite**
Run: `cargo test --lib && cargo test`
Expected: lib count = 103 (was 100, +3), integration count = 27 (unchanged), all passing.
- [ ] **Step 5: Format and lint**
Run: `cargo +nightly fmt && cargo clippy --all-targets -- -D warnings`
Expected: no diff, no warnings.
- [ ] **Step 6: Commit**
```bash
git add src/history.rs
git commit -m "$(cat <<'EOF'
test(history): end-to-end per-event score_sigma override tests
Three integration tests on a 2-team scored event:
- inheritance: Outcome::scores(...) with no override produces
bit-equal posteriors to the same outcome wrapped in
scores_with_sigma(scores, history.score_sigma)
- override-supersedes-default: scores_with_sigma(scores, X) with
history score_sigma(Y) produces bit-equal posteriors to
scores(...) with history score_sigma(X), AND differs measurably
from scores(...) with history score_sigma(Y)
- builder threading: EventBuilder::scores_with_sigma reaches the
ingest path identically to the Outcome constructor
EOF
)"
```
---
## Self-review (writer's note)
**Spec coverage:**
- Spec § "What ships" item 1 (Scored becomes struct variant) → Task 1 step 3 ✓
- Spec § "What ships" item 2 (scores_with_sigma constructor) → Task 1 step 3 ✓
- Spec § "What ships" item 3 (EventBuilder::scores_with_sigma) → Task 2 step 1 ✓
- Spec § "What ships" item 4 (sigma resolution at ingest) → Task 1 step 5 ✓
- Spec § "What ships" item 5 (pattern-match update inventory) → Task 1 step 5 (single site at history.rs:735) ✓
- Spec § "Validation" (debug_assert at constructor) → Task 1 step 3 (in `scores_with_sigma`) ✓
- Spec § "Validation" (debug_assert at ingest) → Task 1 step 5 ✓
- Spec § "Testing strategy" §1 (regression net) → Task 1 step 6, Task 2 step 2, Task 3 step 4 ✓
- Spec § "Testing strategy" §2 test 1 (default-uses-history-default) → Task 3 step 2 test 1 ✓
- Spec § "Testing strategy" §2 test 2 (override-supersedes-default) → Task 3 step 2 test 2 ✓
- Spec § "Testing strategy" §2 test 3 (builder threading) → Task 3 step 2 test 3 ✓
**Out-of-scope items correctly absent:** No `EventKind::Scored` change, no `TimeSlice`/`run_chain` changes, no `Game::scored` standalone API change, no deprecation of `HistoryBuilder::score_sigma`.
**Type / signature consistency:**
- `Outcome::Scored { scores: SmallVec<[f64; 4]>, sigma: Option<f64> }` — Task 1 step 3 (def) and Task 1 step 5 (destructure) match ✓
- `Outcome::scores_with_sigma<I>(scores: I, sigma: f64) -> Outcome` — Task 1 step 3 (def) and Task 2 step 1 (call) match ✓
- `EventBuilder::scores_with_sigma<I>(mut self, scores: I, sigma: f64) -> Self` — Task 2 step 1 (def) and Task 3 step 2 test 3 (call) match ✓
- `sigma.unwrap_or(self.score_sigma)` resolution rule — Task 1 step 5 ✓
**Task split rationale:** Task 1 lands the foundational shape change AND the ingest resolution atomically — every commit boundary builds and tests pass bit-equal. Task 2 is the small additive EventBuilder method, separated for review-focus reasons (it's the user-facing fluent API exposure). Task 3 is purely additive integration tests. Each task is independently committable; no intermediate non-building state.
**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,232 @@
# History → TimeSlice ConvergenceOptions Plumbing
## Summary
Make `History`'s already-public `ConvergenceOptions` (set via
`HistoryBuilder::convergence(...)`) actually reach the within-game
inference loop. Today it's read by the outer `History::converge` sweep
but dropped on the floor when constructing `TimeSlice`s, so users who
opt in to `alpha < 1.0` (Damped EP) on a `History` get nothing — the
inner `run_chain` calls inside `TimeSlice` hardcode
`ConvergenceOptions::default()`.
This spec closes the gap with one focused change: thread
`ConvergenceOptions` from `History` through `TimeSlice` to the three
`Game::*_with_arena` callsites in `time_slice.rs`. No new types, no new
public methods on `History` or `HistoryBuilder` — the user-facing API
already exists.
## Background
After T5 (commit `0705986`) of the Damped EP plan,
`Game::*_with_arena` accepts `convergence: ConvergenceOptions` and
`run_chain` reads `self.convergence.{epsilon, max_iter, alpha}`.
`HistoryBuilder` already has a `convergence(opts)` method (`history.rs:91`)
that stores onto a field on `History`. `History::converge` reads
`self.convergence.{max_iter, epsilon}` for its outer cross-history loop
(`history.rs:437-447`).
The break is here, in `History::add_events_with_prior` at `history.rs:597`:
```rust
let mut time_slice = TimeSlice::new(t, self.p_draw);
```
`self.convergence` is not passed. `TimeSlice` has no convergence field.
The three callsites in `time_slice.rs` that build `Game::*_with_arena`
fall back to `ConvergenceOptions::default()`:
- `Event::iteration_direct` (`time_slice.rs:138-156`)
- `TimeSlice::convergence` (`time_slice.rs:332-345`)
- `TimeSlice::log_evidence` (`time_slice.rs:521-538`)
## Scope
### What ships
1. `TimeSlice<T>` gains a `pub(crate) convergence: ConvergenceOptions`
field set at construction.
2. `TimeSlice::new` signature becomes
`pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self`.
3. `History::add_events_with_prior` (`history.rs:597`) passes
`self.convergence` when constructing new `TimeSlice`s.
4. `Event::iteration_direct` gains a `convergence: ConvergenceOptions`
parameter and forwards it to the `Game::*_with_arena` callsite.
The two callers (`TimeSlice::iteration` at `time_slice.rs:419` and
`:441`) pass `self.convergence`.
5. `TimeSlice::convergence` (the method, not the field) replaces its
hardcoded `crate::ConvergenceOptions::default()` with
`self.convergence`.
6. `TimeSlice::log_evidence` does the same.
7. Five test callsites of `TimeSlice::new(time, p_draw)` updated
mechanically to `TimeSlice::new(time, p_draw, ConvergenceOptions::default())`.
### What does not ship
- No split of `ConvergenceOptions` into outer/inner fields. The
conflation (one `max_iter` covers both the cross-history sweep and
the per-game EP iteration cap) is the user-confirmed design.
- No `Damped` impl in `src/schedule.rs`. The `Schedule` trait is still
not integrated into `run_chain`.
- No nat-param convergence switch.
- No oscillation auto-detect.
- No new `History` or `HistoryBuilder` methods. `convergence(opts)`
already exists and works.
- No changes to `History::converge` — the outer-loop semantics are
unchanged (it already reads `self.convergence`).
## Design
### `TimeSlice<T>` field
```rust
// src/time_slice.rs
pub struct TimeSlice<T: Time = i64> {
// ... existing fields ...
p_draw: f64,
pub(crate) convergence: ConvergenceOptions,
// ... existing fields ...
}
```
### `TimeSlice::new`
```rust
impl<T: Time> TimeSlice<T> {
pub fn new(time: T, p_draw: f64, convergence: ConvergenceOptions) -> Self {
Self {
// ... existing initialisation ...
p_draw,
convergence,
// ...
}
}
}
```
### `History::add_events_with_prior` — single-line fix
At `src/history.rs:597`:
```rust
// before
let mut time_slice = TimeSlice::new(t, self.p_draw);
// after
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
```
### `Event::iteration_direct` parameter
```rust
// src/time_slice.rs
impl Event {
pub(crate) fn iteration_direct(
&mut self,
skills: &mut SkillStore,
agents: &CompetitorStore<i64, ConstantDrift>,
p_draw: f64,
convergence: ConvergenceOptions,
arena: &mut ScratchArena,
) -> /* existing return */ {
// ... existing body, with the Game::*_with_arena calls
// using `convergence` instead of ConvergenceOptions::default() ...
}
}
```
The two callers — `TimeSlice::iteration` at `time_slice.rs:419` and
`:441` — already have `&mut self` access, so they pass
`self.convergence`.
### `TimeSlice::convergence` method (not the field)
The method `pub(crate) fn convergence<D>(&mut self, agents: ...) -> usize`
at `time_slice.rs:447` shares its name with the new field. Rust allows
this (methods and fields live in different namespaces), but it's a
readability hazard. Rename the method to `iterate_to_convergence` to
disambiguate.
This is one rename, six callsites in `history.rs` and the test module.
### Field semantics
`History` keeps the single shared `ConvergenceOptions` struct. The same
`max_iter` covers both the outer sweep and each inner per-game loop.
The same `epsilon` covers both stopping criteria. The `alpha` field is
read only inside `run_chain` (the inner loop); the outer loop
intentionally ignores `alpha` because cross-history damping is a
different mathematical concept and not in scope.
## Testing strategy
### Regression net
The existing 98 lib + 27 integration tests are the bit-equal regression
net. Default `ConvergenceOptions` is unchanged
(`max_iter=30, epsilon=1e-6, alpha=1.0`), and `TimeSlice` was already
using exactly that since T5. The only behavioural difference is for
users who actually pass non-default options through
`HistoryBuilder::convergence(...)` — and there are no current tests that
do that **and** compare posteriors, so all goldens stay bit-equal.
### New tests
1. **`history_propagates_convergence_to_inner_run_chain`** (in
`src/history.rs` test module):
- Build a History with `convergence(ConvergenceOptions { max_iter: 1, ..Default::default() })`.
- Add a small batch of events that needs more than one inner EP iteration to converge (e.g. a 4-team game per slice).
- `converge()`, capture posteriors.
- Build a fresh History with default options on the same events.
- `converge()`, capture posteriors.
- Assert the two sets of posteriors differ measurably (max diff > 1e-6).
- Proves the inner loop honours the propagated `max_iter`. Today (without this change) the assertion would fail because both Histories use default inside.
2. **`history_with_damping_reaches_same_fixed_point_as_undamped`** (same
test module):
- Build a History with `convergence(ConvergenceOptions { alpha: 0.5, max_iter: 200, ..Default::default() })`.
- Same events as above.
- `converge()`, capture posteriors.
- Build a default-options History on the same events.
- `converge()`, capture posteriors.
- Assert per-player posteriors agree within 1e-3.
- Proves damping doesn't break convergence on the History path.
If the second test's max diff is too large, raise `max_iter` further
(damping needs more iterations to reach the same fixed point).
## Verification gates
```bash
cargo +nightly fmt
cargo clippy --all-targets -- -D warnings
cargo test --lib
cargo test
```
All must succeed. Test count grows by exactly 2 (the two new tests).
## Risks
- **`TimeSlice::new` is `pub`.** Adding the third parameter is a
breaking change to a public constructor. In a 0.1.x crate this is
acceptable, but flag it in the commit message.
- **`TimeSlice::convergence` method rename.** Renaming
`convergence``iterate_to_convergence` touches `history.rs` and the
TimeSlice test module. The rename is mechanical and improves
readability where the field and method would otherwise share a name.
- **Cross-history alpha semantics.** A user who sets `alpha = 0.5` on
a `History` gets damping inside every per-game loop, but the outer
`History::converge` sweep is undamped. This is the correct semantic
(alpha is a within-EP-graph concept) but it's worth documenting in
the `ConvergenceOptions::alpha` doc comment so users don't expect
cross-slice damping. Add one sentence to the existing doc comment.
## Out-of-scope follow-ups
- Wire `Schedule` trait into `run_chain` — Damped becomes a `Schedule`
impl alongside `EpsilonOrMax`.
- Per-loop `ConvergenceOptions` split (outer / inner).
- `Residual` schedule.
- Per-event `EventKind::Scored.score_sigma` override (still
history-wide today).
@@ -0,0 +1,292 @@
# Per-Event `score_sigma` Override
## Summary
Let users specify a per-event noise override on `Outcome::Scored`.
Today every scored event in a `History` shares the single
`HistoryBuilder::score_sigma` value (default `1.0`); a user who wants
to say "this match was a clean blowout, trust the margin more" or
"this one was a disrupted scrappy game, trust it less" has no way to
do so.
The override is resolved at ingest time and stored as a plain `f64`
on the existing `EventKind::Scored { score_sigma }` payload, so
`TimeSlice` and `run_chain` need zero changes. The work is purely on
the public API surface: `Outcome::Scored` becomes a struct variant
with an `Option<f64> sigma` field; two builder methods on `Outcome`
and `EventBuilder` cover the explicit-override path.
## Background
`Outcome::Scored(SmallVec<[f64; 4]>)` is the public per-team-score
variant (`src/outcome.rs:20`). It's constructed via
`Outcome::scores(I)` (`src/outcome.rs:44`) or
`EventBuilder::scores(I)` (`src/event_builder.rs:79`).
When `History::add_events` ingests a Scored outcome, it always uses
the history-wide default:
```rust
// src/history.rs:735-740
crate::Outcome::Scored(scores) => {
kinds.push(EventKind::Scored {
score_sigma: self.score_sigma,
});
scores.to_vec()
}
```
The downstream `EventKind::Scored { score_sigma: f64 }`
(`src/time_slice.rs:51`) is already per-event-shaped — every Event
carries its own copy. The constraint is purely at the ingest boundary.
This was flagged as deferred tech debt during the T4-MarginFactor
work: "EventKind::Scored.score_sigma payload is always history-wide
today; per-event override deferred."
## Scope
### What ships
1. `Outcome::Scored` becomes a struct variant:
`Scored { scores: SmallVec<[f64; 4]>, sigma: Option<f64> }`.
`None` = use history default; `Some(s)` = override.
2. New constructor `Outcome::scores_with_sigma(scores, sigma)` on
`Outcome`. Existing `Outcome::scores(I)` keeps the same shape but
builds with `sigma: None`.
3. New builder method `EventBuilder::scores_with_sigma(scores, sigma)`
on `EventBuilder`.
4. `History::add_events` resolves `sigma.unwrap_or(self.score_sigma)`
when converting an `Outcome::Scored` to `EventKind::Scored`.
5. Mechanical pattern-match updates at every site that destructures
`Outcome::Scored(...)` as a tuple. Estimate ~510 sites across
`src/`, `tests/`, `examples/`, `benches/`.
### What does not ship
- No change to `EventKind::Scored` (already per-event).
- No change to `TimeSlice` or `run_chain`.
- No change to `Game::scored` standalone API
(it still takes `score_sigma` via `GameOptions::score_sigma`).
- No deprecation of `HistoryBuilder::score_sigma` — the history-wide
default is still useful as a common-case fallback.
## Design
### `Outcome` enum change
```rust
// src/outcome.rs
#[derive(Clone, Debug)]
pub enum Outcome {
Ranked(SmallVec<[u32; 4]>),
Scored {
scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
sigma: Option<f64>,
},
}
```
The variant shape changes from tuple to struct. Pattern matches that
extract the scores switch from `Outcome::Scored(scores)` to
`Outcome::Scored { scores, .. }` (or `{ scores, sigma }` where the
sigma is needed).
### `Outcome` constructors
```rust
impl Outcome {
/// Per-team continuous scores; uses HistoryBuilder::score_sigma default.
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored {
scores: scores.into_iter().collect(),
sigma: None,
}
}
/// Per-team scores with explicit per-event noise override.
///
/// `sigma` must be > 0.0; debug_assert.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(
scores: I,
sigma: f64,
) -> Self {
debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})");
Self::Scored {
scores: scores.into_iter().collect(),
sigma: Some(sigma),
}
}
}
```
`Outcome::scores(I)` keeps the existing function signature exactly —
its only behavioural change is the internal struct construction. The
existing `as_scores()`, `team_count()`, etc. accessors keep their
public signatures (they return `Option<&[f64]>` and `usize`); their
internal pattern matches update mechanically.
### `EventBuilder` method
```rust
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
where
T: Time,
D: Drift<T>,
O: Observer<T>,
K: Eq + std::hash::Hash + Clone,
{
/// Per-team scores; uses HistoryBuilder::score_sigma default.
pub fn scores<I: IntoIterator<Item = f64>>(mut self, scores: I) -> Self {
self.event.outcome = crate::Outcome::scores(scores);
self
}
/// Per-team scores with explicit per-event noise override.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(
mut self,
scores: I,
sigma: f64,
) -> Self {
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
self
}
}
```
The existing `.scores(...)` builder method stays — its body changes
trivially because `Outcome::scores(I)` still has the same signature.
`.scores_with_sigma(...)` is the new method.
### Sigma resolution
In `History::add_events` at `src/history.rs:735`:
```rust
crate::Outcome::Scored { scores, sigma } => {
let resolved = sigma.unwrap_or(self.score_sigma);
debug_assert!(
resolved > 0.0,
"resolved score_sigma must be > 0.0 (got {resolved})"
);
kinds.push(EventKind::Scored {
score_sigma: resolved,
});
scores.to_vec()
}
```
Resolution at ingest time means downstream code keeps a plain `f64`.
No `Option` propagates further.
### Validation
- `Outcome::scores_with_sigma(_, sigma)` debug-asserts `sigma > 0.0`
at construction.
- `History::add_events` debug-asserts the resolved sigma is `> 0.0`
(catches both inherited and overridden paths).
- `HistoryBuilder::score_sigma(s)` keeps its existing positive
assertion.
The default sigma at the History level (`1.0`) is positive, so an
event with `sigma = None` against a default-built History always
passes the resolved-sigma assertion trivially.
### Pattern-match update inventory
Every site that destructures `Outcome::Scored(_)` as a tuple needs
updating. Known sites:
- `src/outcome.rs`: the `team_count()`, `as_scores()`, `as_ranks()`
match arms (`src/outcome.rs:51`, `:58`, `:64`).
- `src/history.rs:735`: the conversion arm (this is also where the
resolution rule lands).
- Any test in `src/outcome.rs` test mod that constructs
`Outcome::Scored(...)` literally.
- Any callsite in `src/`, `tests/`, `examples/`, `benches/`,
`src/game.rs` that pattern-matches the variant.
The compiler surfaces every site at `cargo build`. Locating them is
mechanical.
## Testing strategy
### Regression net
Existing 100 lib + 27 integration tests are the bit-equal regression
net for the `sigma = None` path. Every existing test that uses
`Outcome::scores(...)` or `EventBuilder::scores(...)` should
continue to produce identical posteriors — the resolved sigma equals
the history default (which equals what the hardcoded path produced).
### New tests
Three additions in the `src/history.rs` test module:
1. **`outcome_scores_default_sigma_uses_history_default`** — build a
History with `score_sigma(0.5)`, add a 2-team event via
`Outcome::scores([3.0, 1.0])` (no override), capture posteriors.
Build a second History identical except using
`Outcome::scores_with_sigma([3.0, 1.0], 0.5)` (override matches
default). Assert posteriors are bit-equal across the two paths.
2. **`outcome_scores_with_sigma_overrides_history_default`** — build a
History with `score_sigma(0.5)`, add an event via
`Outcome::scores_with_sigma([3.0, 1.0], 2.0)`. Build a second
History with `score_sigma(2.0)` and add the same event via
`Outcome::scores([3.0, 1.0])`. Assert posteriors are bit-equal.
Then build a third History with `score_sigma(0.5)` and add via
`Outcome::scores([3.0, 1.0])` (no override). Assert this third
one's posteriors differ measurably from the override path
(max diff > 1e-6) — proves the override actually changes
inference.
3. **`event_builder_scores_with_sigma_threading`** — same shape as
#2 but constructed via the fluent builder
`h.event(0).team(["a"]).team(["b"]).scores_with_sigma([3.0, 1.0], 2.0).commit()`.
Proves the builder method works end-to-end.
### Pattern-match update test impact
Existing tests in `src/outcome.rs` that construct
`Outcome::Scored(...)` literally need updating to the struct shape.
Mechanical change; no new tests required.
## Verification gates
```bash
cargo +nightly fmt
cargo clippy --all-targets -- -D warnings
cargo test --lib
cargo test
```
Test count grows by 3.
## Risks
- **Public API breaking change.** `Outcome::Scored` variant shape
changes from tuple to struct. Any downstream consumer
pattern-matching on the tuple form breaks. In a 0.1.x crate this
is acceptable; flag it in the commit message.
- **Mechanical breadth.** The pattern-match updates touch several
files. They're all caught by the compiler so the risk is low, but
the diff will look bigger than the actual logical change.
- **Two ways to do the same thing.** `Outcome::scores_with_sigma(..)`
and `EventBuilder::scores_with_sigma(..)` both produce the same
outcome. This is intentional — the constructor is the underlying
primitive; the builder method is the ergonomic wrapper. Same
pattern as the existing `Outcome::scores(..)` /
`EventBuilder::scores(..)` pair.
## Out-of-scope follow-ups
- Per-event override of other config currently history-wide
(`p_draw`, drift, beta) — same architectural pattern would apply
but each is its own design decision.
- Validation upgrade from `debug_assert!` to a `Result` at the
Outcome construction boundary.
- Schedule trait integration with `run_chain`, `Residual` schedule,
`SynergyFactor` (still pending from the larger spec).
+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();
+22
View File
@@ -8,6 +8,16 @@ 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 inside a single game 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]`.
///
/// Applies only to the within-game EP loop (`run_chain`). The outer
/// `History::converge` cross-history sweep is undamped regardless of
/// this value — cross-slice damping is a different concept and not
/// in scope.
pub alpha: f64,
} }
impl Default for ConvergenceOptions { impl Default for ConvergenceOptions {
@@ -15,6 +25,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 +40,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);
}
}
+9
View File
@@ -81,6 +81,15 @@ where
self self
} }
/// Set explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
/// Must be `> 0.0`; debug-asserts otherwise via `Outcome::scores_with_sigma`.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
self
}
/// Mark team `winner_idx` as winner; others tied for last. /// Mark team `winner_idx` as winner; others tied for last.
pub fn winner(mut self, winner_idx: u32) -> Self { pub fn winner(mut self, winner_idx: u32) -> Self {
self.event.outcome = Outcome::winner(winner_idx, self.event.teams.len() as u32); self.event.outcome = Outcome::winner(winner_idx, self.event.teams.len() as u32);
+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);
}
} }
+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);
}
} }
+189 -16
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,
}; };
@@ -239,6 +274,10 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
{ {
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();
arena.sort_buf.extend(0..n_teams); arena.sort_buf.extend(0..n_teams);
@@ -267,7 +306,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
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);
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() {
@@ -275,7 +314,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
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();
@@ -289,7 +328,7 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
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();
@@ -305,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.
@@ -429,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(
@@ -465,6 +510,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
scores, scores,
weights, weights,
options.score_sigma, options.score_sigma,
options.convergence,
)) ))
} }
@@ -526,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();
@@ -553,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();
@@ -572,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(),
); );
@@ -605,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();
@@ -621,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();
@@ -632,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];
@@ -664,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();
@@ -691,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();
@@ -726,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();
@@ -762,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();
@@ -813,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();
@@ -846,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();
@@ -870,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();
@@ -894,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();
@@ -921,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();
@@ -948,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();
@@ -967,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);
@@ -989,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();
@@ -1008,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();
@@ -1116,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();
@@ -1150,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();
@@ -1184,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();
@@ -1222,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();
@@ -1235,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();
@@ -1244,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}"
);
}
} }
+72 -2
View File
@@ -53,7 +53,11 @@ impl Gaussian {
#[inline] #[inline]
pub fn mu(&self) -> f64 { pub fn mu(&self) -> f64 {
if self.pi == 0.0 { // A non-positive precision is an improper (uninformative) Gaussian — its mean is
// undefined. Treat it like `pi == 0` and return 0. EP message cancellation can land
// `pi` on a tiny negative value (round-off of exactly zero); without this guard
// `tau / pi` would yield a spurious finite mean.
if self.pi <= 0.0 {
0.0 0.0
} else { } else {
self.tau / self.pi self.tau / self.pi
@@ -62,7 +66,10 @@ impl Gaussian {
#[inline] #[inline]
pub fn sigma(&self) -> f64 { pub fn sigma(&self) -> f64 {
if self.pi == 0.0 { // A non-positive precision is improper → infinite standard deviation. Guarding
// `pi <= 0.0` (not just `== 0.0`) keeps `1.0 / pi.sqrt()` from returning NaN when EP
// cancellation produces a tiny negative precision (round-off of exactly zero).
if self.pi <= 0.0 {
f64::INFINITY f64::INFINITY
} else if self.pi.is_infinite() { } else if self.pi.is_infinite() {
0.0 0.0
@@ -96,6 +103,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 {
@@ -162,6 +181,28 @@ impl ops::Div<Gaussian> for Gaussian {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn non_positive_precision_is_improper_not_nan() {
// EP message cancellation can leave `pi` a tiny negative (round-off of exactly zero).
// Such a Gaussian is improper/uninformative: mu() must be 0 and sigma() infinite, not
// NaN. A NaN here propagates through the moment-space `Sub` in the game chain and
// poisons every skill in the slice.
let tiny_neg = Gaussian::from_natural(-5.55e-17, -8.88e-16);
assert_eq!(tiny_neg.mu(), 0.0);
assert!(tiny_neg.sigma().is_infinite());
// A frankly-negative precision is treated the same way.
let neg = Gaussian::from_natural(-1.0, 2.0);
assert_eq!(neg.mu(), 0.0);
assert!(neg.sigma().is_infinite());
// Subtracting such a message must not produce NaN (the original failure path).
let proper = Gaussian::from_ms(9.75, 1.256);
let diff = proper - tiny_neg;
assert!(diff.pi().is_finite() && !diff.pi().is_nan());
assert!(diff.tau().is_finite() && !diff.tau().is_nan());
}
#[test] #[test]
fn test_add() { fn test_add() {
let n = Gaussian::from_ms(25.0, 25.0 / 3.0); let n = Gaussian::from_ms(25.0, 25.0 / 3.0);
@@ -231,4 +272,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);
}
} }
+253 -3
View File
@@ -594,7 +594,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
agent.message = time_slice.forward_prior_out(&agent_idx); agent.message = time_slice.forward_prior_out(&agent_idx);
} }
} else { } else {
let mut time_slice = TimeSlice::new(t, self.p_draw); let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents); time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents);
self.time_slices.insert(k, time_slice); self.time_slices.insert(k, time_slice);
@@ -732,9 +732,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
kinds.push(EventKind::Ranked); kinds.push(EventKind::Ranked);
ranks.iter().map(|&r| max_rank - r as f64).collect() ranks.iter().map(|&r| max_rank - r as f64).collect()
} }
crate::Outcome::Scored(scores) => { crate::Outcome::Scored { scores, sigma } => {
let resolved = sigma.unwrap_or(self.score_sigma);
debug_assert!(
resolved > 0.0,
"resolved score_sigma must be > 0.0 (got {resolved})"
);
kinds.push(EventKind::Scored { kinds.push(EventKind::Scored {
score_sigma: self.score_sigma, score_sigma: resolved,
}); });
scores.to_vec() scores.to_vec()
} }
@@ -838,6 +843,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 +1374,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 +1692,7 @@ mod tests {
.convergence(ConvergenceOptions { .convergence(ConvergenceOptions {
max_iter: 30, max_iter: 30,
epsilon: 1e-6, epsilon: 1e-6,
alpha: 1.0,
}) })
.build(); .build();
@@ -1711,4 +1719,246 @@ mod tests {
fn history_builder_rejects_zero_score_sigma() { fn history_builder_rejects_zero_score_sigma() {
let _ = History::builder().score_sigma(0.0).build(); let _ = History::builder().score_sigma(0.0).build();
} }
#[test]
fn history_propagates_convergence_to_inner_run_chain() {
use crate::ConvergenceOptions;
let events_for =
|h: &mut History<i64, ConstantDrift, crate::observer::NullObserver, &'static str>| {
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.ranking([0u32, 1, 2, 3])
.commit()
.unwrap();
};
let mut h_capped: History<i64, _, _, &'static str> = History::builder()
.convergence(ConvergenceOptions {
max_iter: 1,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_capped);
h_capped.converge().unwrap();
let mut h_full: History<i64, _, _, &'static str> = History::builder().build();
events_for(&mut h_full);
h_full.converge().unwrap();
let curves_capped = h_capped.learning_curves();
let curves_full = h_full.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, capped_pts) in curves_capped.iter() {
let full_pts = curves_full.get(key).expect("agent missing in full");
for (capped, full) in capped_pts.iter().zip(full_pts.iter()) {
max_diff = max_diff.max((capped.1.mu() - full.1.mu()).abs());
max_diff = max_diff.max((capped.1.sigma() - full.1.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"max_iter=1 inner loop should differ from default; max_diff={max_diff}"
);
}
#[test]
fn history_with_damping_reaches_same_fixed_point_as_undamped() {
use crate::ConvergenceOptions;
let events_for =
|h: &mut History<i64, ConstantDrift, crate::observer::NullObserver, &'static str>| {
h.event(0)
.team(["a"])
.team(["b"])
.team(["c"])
.team(["d"])
.ranking([0u32, 1, 2, 3])
.commit()
.unwrap();
};
let mut h_undamped: History<i64, _, _, &'static str> = History::builder().build();
events_for(&mut h_undamped);
h_undamped.converge().unwrap();
let mut h_damped: History<i64, _, _, &'static str> = History::builder()
.convergence(ConvergenceOptions {
alpha: 0.5,
max_iter: 200,
..ConvergenceOptions::default()
})
.build();
events_for(&mut h_damped);
h_damped.converge().unwrap();
let curves_u = h_undamped.learning_curves();
let curves_d = h_damped.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, u_pts) in curves_u.iter() {
let d_pts = curves_d.get(key).expect("agent missing in damped");
for (u, d) in u_pts.iter().zip(d_pts.iter()) {
max_diff = max_diff.max((u.1.mu() - d.1.mu()).abs());
max_diff = max_diff.max((u.1.sigma() - d.1.sigma()).abs());
}
}
assert!(
max_diff < 1e-3,
"α=0.5 should reach the same fixed point as α=1.0; max_diff={max_diff}"
);
}
#[test]
fn outcome_scores_default_sigma_uses_history_default() {
use crate::Outcome;
// Path A: explicit sigma=0.5 via override.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], 0.5),
}])
.unwrap();
h_a.converge().unwrap();
// Path B: history-wide default 0.5, no per-event override.
let mut h_b = crate::History::builder().score_sigma(0.5).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
h_b.converge().unwrap();
// Inheritance: posteriors must be bit-equal.
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("agent missing in path B");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
}
}
}
#[test]
fn outcome_scores_with_sigma_overrides_history_default() {
use crate::Outcome;
// Path A: history-wide default 0.5, per-event override 2.0.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
}])
.unwrap();
h_a.converge().unwrap();
// Path B: history-wide default 2.0, no per-event override.
let mut h_b = crate::History::builder().score_sigma(2.0).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
h_b.converge().unwrap();
// Override == default-set-to-the-override-value: bit-equal.
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("agent missing in path B");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
}
}
// Path C: history-wide default 0.5, no override. Different sigma → different posteriors.
let mut h_c = crate::History::builder().score_sigma(0.5).build();
h_c.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores([3.0, 1.0]),
}])
.unwrap();
h_c.converge().unwrap();
let curves_c = h_c.learning_curves();
let mut max_diff: f64 = 0.0;
for (key, a_pts) in curves_a.iter() {
let c_pts = curves_c.get(key).expect("agent missing in path C");
for (a, c) in a_pts.iter().zip(c_pts.iter()) {
max_diff = max_diff.max((a.1.mu() - c.1.mu()).abs());
max_diff = max_diff.max((a.1.sigma() - c.1.sigma()).abs());
}
}
assert!(
max_diff > 1e-6,
"override should produce different posteriors from inherited default; max_diff={max_diff}"
);
}
#[test]
fn event_builder_scores_with_sigma_threading() {
use crate::Outcome;
// Path A: builder fluent API with sigma override.
let mut h_a = crate::History::builder().score_sigma(0.5).build();
h_a.event(0_i64)
.team(["a"])
.team(["b"])
.scores_with_sigma([3.0, 1.0], 2.0)
.commit()
.unwrap();
h_a.converge().unwrap();
// Path B: same outcome via the explicit Outcome constructor.
let mut h_b = crate::History::builder().score_sigma(0.5).build();
h_b.add_events([crate::Event {
time: 0_i64,
teams: smallvec::smallvec![
crate::Team::with_members([crate::Member::new("a")]),
crate::Team::with_members([crate::Member::new("b")]),
],
outcome: Outcome::scores_with_sigma([3.0, 1.0], 2.0),
}])
.unwrap();
h_b.converge().unwrap();
let curves_a = h_a.learning_curves();
let curves_b = h_b.learning_curves();
for (key, a_pts) in curves_a.iter() {
let b_pts = curves_b.get(key).expect("agent missing");
for (a, b) in a_pts.iter().zip(b_pts.iter()) {
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at agent {key:?}");
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at agent {key:?}");
}
}
}
} }
+62 -10
View File
@@ -1,7 +1,7 @@
//! Outcome of a match. //! Outcome of a match.
//! //!
//! `Ranked(ranks)` for ordinal results; `Scored(scores)` for continuous //! `Ranked(ranks)` for ordinal results; `Scored { scores, sigma }` for
//! per-team scores (engages `MarginFactor` in the engine). //! continuous per-team scores (engages `MarginFactor` in the engine).
use smallvec::SmallVec; use smallvec::SmallVec;
@@ -10,14 +10,20 @@ use smallvec::SmallVec;
/// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those /// `Ranked(ranks)`: lower rank = better. Equal ranks mean a tie between those
/// teams. `ranks.len()` must equal the number of teams in the event. /// teams. `ranks.len()` must equal the number of teams in the event.
/// ///
/// `Scored(scores)`: higher score = better. Adjacent (sorted) pairs feed /// `Scored { scores, sigma }`: higher score = better. Adjacent (sorted) pairs
/// observed margins to `MarginFactor`. `scores.len()` must equal the number /// feed observed margins to `MarginFactor`. `scores.len()` must equal the
/// of teams in the event. /// number of teams in the event. `sigma` overrides `HistoryBuilder::score_sigma`
/// when `Some`; `None` inherits the history default.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
#[non_exhaustive] #[non_exhaustive]
pub enum Outcome { pub enum Outcome {
Ranked(SmallVec<[u32; 4]>), Ranked(SmallVec<[u32; 4]>),
Scored(SmallVec<[f64; 4]>), Scored {
scores: SmallVec<[f64; 4]>,
/// Per-event noise override. `None` means inherit
/// `HistoryBuilder::score_sigma`. Must be `> 0.0` if `Some`.
sigma: Option<f64>,
},
} }
impl Outcome { impl Outcome {
@@ -41,27 +47,42 @@ impl Outcome {
} }
/// Explicit per-team continuous scores; higher = better. /// Explicit per-team continuous scores; higher = better.
/// Inherits `HistoryBuilder::score_sigma` for the noise model.
pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self { pub fn scores<I: IntoIterator<Item = f64>>(scores: I) -> Self {
Self::Scored(scores.into_iter().collect()) Self::Scored {
scores: scores.into_iter().collect(),
sigma: None,
}
}
/// Explicit per-team continuous scores with a per-event noise override.
///
/// `sigma` must be `> 0.0`; debug-asserts otherwise.
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(scores: I, sigma: f64) -> Self {
debug_assert!(sigma > 0.0, "score_sigma must be > 0.0 (got {sigma})");
Self::Scored {
scores: scores.into_iter().collect(),
sigma: Some(sigma),
}
} }
pub fn team_count(&self) -> usize { pub fn team_count(&self) -> usize {
match self { match self {
Self::Ranked(r) => r.len(), Self::Ranked(r) => r.len(),
Self::Scored(s) => s.len(), Self::Scored { scores, .. } => scores.len(),
} }
} }
pub(crate) fn as_ranks(&self) -> Option<&[u32]> { pub(crate) fn as_ranks(&self) -> Option<&[u32]> {
match self { match self {
Self::Ranked(r) => Some(r), Self::Ranked(r) => Some(r),
Self::Scored(_) => None, Self::Scored { .. } => None,
} }
} }
pub(crate) fn as_scores(&self) -> Option<&[f64]> { pub(crate) fn as_scores(&self) -> Option<&[f64]> {
match self { match self {
Self::Scored(s) => Some(s), Self::Scored { scores, .. } => Some(scores),
Self::Ranked(_) => None, Self::Ranked(_) => None,
} }
} }
@@ -122,4 +143,35 @@ mod tests {
assert!(o.as_scores().is_none()); assert!(o.as_scores().is_none());
assert!(o.as_ranks().is_some()); assert!(o.as_ranks().is_some());
} }
#[test]
fn scores_with_sigma_round_trips() {
let o = Outcome::scores_with_sigma([10.0, 4.0], 0.5);
assert_eq!(o.team_count(), 2);
assert_eq!(o.as_scores(), Some(&[10.0, 4.0][..]));
}
#[test]
fn scores_constructor_leaves_sigma_unset() {
let o = Outcome::scores([3.0, 1.0]);
match o {
Outcome::Scored { scores: _, sigma } => assert!(sigma.is_none()),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
#[test]
fn scores_with_sigma_sets_sigma_some() {
let o = Outcome::scores_with_sigma([3.0, 1.0], 2.0);
match o {
Outcome::Scored { scores: _, sigma } => assert_eq!(sigma, Some(2.0)),
Outcome::Ranked(_) => panic!("expected Scored variant"),
}
}
#[test]
#[should_panic(expected = "score_sigma must be > 0.0")]
fn scores_with_sigma_rejects_zero() {
let _ = Outcome::scores_with_sigma([3.0, 1.0], 0.0);
}
} }
+63 -27
View File
@@ -133,17 +133,23 @@ impl Event {
skills: &mut SkillStore, skills: &mut SkillStore,
agents: &CompetitorStore<T, D>, agents: &CompetitorStore<T, D>,
p_draw: f64, p_draw: f64,
convergence: crate::ConvergenceOptions,
arena: &mut ScratchArena, arena: &mut ScratchArena,
) { ) {
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(teams, &result, &self.weights, p_draw, arena) Game::ranked_with_arena(teams, &result, &self.weights, p_draw, convergence, arena)
}
EventKind::Scored { score_sigma } => {
Game::scored_with_arena(teams, &result, &self.weights, score_sigma, arena)
} }
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&self.weights,
score_sigma,
convergence,
arena,
),
}; };
for (t, team) in self.teams.iter_mut().enumerate() { for (t, team) in self.teams.iter_mut().enumerate() {
@@ -165,17 +171,19 @@ pub struct TimeSlice<T: Time = i64> {
pub(crate) skills: SkillStore, pub(crate) skills: SkillStore,
pub(crate) time: T, pub(crate) time: T,
p_draw: f64, p_draw: f64,
pub(crate) convergence: crate::ConvergenceOptions,
arena: ScratchArena, arena: ScratchArena,
pub(crate) color_groups: ColorGroups, pub(crate) color_groups: ColorGroups,
} }
impl<T: Time> TimeSlice<T> { impl<T: Time> TimeSlice<T> {
pub fn new(time: T, p_draw: f64) -> Self { pub fn new(time: T, p_draw: f64, convergence: crate::ConvergenceOptions) -> Self {
Self { Self {
events: Vec::new(), events: Vec::new(),
skills: SkillStore::new(), skills: SkillStore::new(),
time, time,
p_draw, p_draw,
convergence,
arena: ScratchArena::new(), arena: ScratchArena::new(),
color_groups: ColorGroups::new(), color_groups: ColorGroups::new(),
} }
@@ -322,6 +330,7 @@ impl<T: Time> TimeSlice<T> {
&result, &result,
&event.weights, &event.weights,
self.p_draw, self.p_draw,
self.convergence,
&mut self.arena, &mut self.arena,
), ),
EventKind::Scored { score_sigma } => Game::scored_with_arena( EventKind::Scored { score_sigma } => Game::scored_with_arena(
@@ -329,6 +338,7 @@ impl<T: Time> TimeSlice<T> {
&result, &result,
&event.weights, &event.weights,
score_sigma, score_sigma,
self.convergence,
&mut self.arena, &mut self.arena,
), ),
}; };
@@ -382,6 +392,7 @@ impl<T: Time> TimeSlice<T> {
} }
let range = self.color_groups.color_range(color_idx); let range = self.color_groups.color_range(color_idx);
let p_draw = self.p_draw; let p_draw = self.p_draw;
let convergence = self.convergence;
if group_len >= RAYON_THRESHOLD { if group_len >= RAYON_THRESHOLD {
// Obtain a raw pointer from the unique `&mut self.skills` reference. // Obtain a raw pointer from the unique `&mut self.skills` reference.
@@ -399,12 +410,18 @@ impl<T: Time> TimeSlice<T> {
ARENA.with(|cell| { ARENA.with(|cell| {
let mut arena = cell.borrow_mut(); let mut arena = cell.borrow_mut();
arena.reset(); arena.reset();
ev.iteration_direct(skills, agents, p_draw, &mut arena); ev.iteration_direct(skills, agents, p_draw, convergence, &mut arena);
}); });
}); });
} else { } else {
for ev in &mut self.events[range] { for ev in &mut self.events[range] {
ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena); ev.iteration_direct(
&mut self.skills,
agents,
p_draw,
self.convergence,
&mut self.arena,
);
} }
} }
} }
@@ -426,13 +443,22 @@ impl<T: Time> TimeSlice<T> {
// allowed within a single method body. // allowed within a single method body.
let p_draw = self.p_draw; let p_draw = self.p_draw;
for ev in &mut self.events[range] { for ev in &mut self.events[range] {
ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena); ev.iteration_direct(
&mut self.skills,
agents,
p_draw,
self.convergence,
&mut self.arena,
);
} }
} }
} }
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn convergence<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) -> usize { pub(crate) fn iterate_to_convergence<D: Drift<T>>(
&mut self,
agents: &CompetitorStore<T, D>,
) -> usize {
let epsilon = 1e-6; let epsilon = 1e-6;
let iterations = 20; let iterations = 20;
@@ -504,16 +530,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,
.evidence &result,
.ln() &event.weights,
} self.p_draw,
EventKind::Scored { score_sigma } => { self.convergence,
Game::scored_with_arena(teams, &result, &event.weights, score_sigma, arena) arena,
.evidence )
.ln() .evidence
} .ln(),
EventKind::Scored { score_sigma } => Game::scored_with_arena(
teams,
&result,
&event.weights,
score_sigma,
self.convergence,
arena,
)
.evidence
.ln(),
} }
}; };
@@ -621,7 +657,7 @@ mod tests {
); );
} }
let mut time_slice = TimeSlice::new(0i64, 0.0); let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
time_slice.add_events( time_slice.add_events(
vec![ vec![
@@ -668,7 +704,7 @@ mod tests {
epsilon = 1e-6 epsilon = 1e-6
); );
assert_eq!(time_slice.convergence(&agents), 1); assert_eq!(time_slice.iterate_to_convergence(&agents), 1);
} }
#[test] #[test]
@@ -698,7 +734,7 @@ mod tests {
); );
} }
let mut time_slice = TimeSlice::new(0i64, 0.0); let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
time_slice.add_events( time_slice.add_events(
vec![ vec![
@@ -730,7 +766,7 @@ mod tests {
epsilon = 1e-6 epsilon = 1e-6
); );
assert!(time_slice.convergence(&agents) > 1); assert!(time_slice.iterate_to_convergence(&agents) > 1);
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -778,7 +814,7 @@ mod tests {
); );
} }
let mut time_slice = TimeSlice::new(0i64, 0.0); let mut time_slice = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
time_slice.add_events( time_slice.add_events(
vec![ vec![
@@ -792,7 +828,7 @@ mod tests {
&agents, &agents,
); );
time_slice.convergence(&agents); time_slice.iterate_to_convergence(&agents);
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -826,7 +862,7 @@ mod tests {
assert_eq!(time_slice.events.len(), 6); assert_eq!(time_slice.events.len(), 6);
time_slice.convergence(&agents); time_slice.iterate_to_convergence(&agents);
let post = time_slice.posteriors(); let post = time_slice.posteriors();
@@ -876,7 +912,7 @@ mod tests {
); );
} }
let mut ts = TimeSlice::new(0i64, 0.0); let mut ts = TimeSlice::new(0i64, 0.0, crate::ConvergenceOptions::default());
ts.add_events( ts.add_events(
vec![ vec![
+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();
+71
View File
@@ -0,0 +1,71 @@
//! Regression: a single time slice with many distinct competitors must converge to finite
//! skills. Before the `pi <= 0` guard in `Gaussian::mu()/sigma()`, EP message cancellation
//! produced a tiny-negative precision whose `sigma() = 1/sqrt(pi)` was NaN, which the
//! moment-space `Sub` in the game chain propagated into every skill once the slice grew past
//! ~75 competitors (e.g. a real ranking dataset with hundreds of players).
use trueskill_tt::{ConstantDrift, ConvergenceOptions, EPSILON, History, ITERATIONS, NullObserver};
/// Tiny deterministic LCG — avoids a dev-dependency on `rand`.
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() >> 33) as usize % n
}
fn coin(&mut self) -> bool {
self.next() & 1 == 0
}
}
fn nan_after_fit(players: usize) -> usize {
let mut h: History<i64, ConstantDrift, NullObserver, String> = History::builder_with_key()
.beta(1.0)
.sigma(6.0)
.drift(ConstantDrift(0.1))
.convergence(ConvergenceOptions {
max_iter: ITERATIONS,
epsilon: EPSILON,
..Default::default()
})
.build();
let ids: Vec<String> = (0..players).map(|i| format!("p{i:04}")).collect();
let mut rng = Lcg(1);
for _ in 0..(players * 4) {
let a = rng.below(players);
let mut b = rng.below(players - 1);
if b >= a {
b += 1;
}
let (w, l) = if rng.coin() { (a, b) } else { (b, a) };
h.record_winner(&ids[w], &ids[l], 0).unwrap();
}
h.converge().unwrap();
ids.iter()
.filter(|id| {
h.current_skill(id.as_str())
.map(|g| !g.mu().is_finite() || !g.sigma().is_finite())
.unwrap_or(true)
})
.count()
}
#[test]
fn many_competitors_converge_to_finite_skills() {
// The NaN regression onset was between 70 and 80 competitors; 250 is comfortably past it
// and in the range of a real ranking dataset.
for players in [12usize, 75, 150, 250] {
assert_eq!(
nan_after_fit(players),
0,
"{players}-competitor history produced NaN skills"
);
}
}
+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();