Compare commits
2 Commits
726896a2ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bf3e7e294 | |||
| d2aab82c1e |
144
CHANGELOG.md
144
CHANGELOG.md
@@ -2,6 +2,150 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## Unreleased — T3 concurrency
|
||||
|
||||
Adds rayon-backed parallel paths per Section 6 of
|
||||
`docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- `Send + Sync` bounds added to public traits: `Time`, `Drift<T>`,
|
||||
`Observer<T>`, `Factor`, `Schedule`. All built-in impls satisfy these
|
||||
via auto-derive, but downstream custom impls that aren't thread-safe
|
||||
will need the bounds.
|
||||
|
||||
### New
|
||||
|
||||
- Opt-in `rayon` cargo feature. When enabled:
|
||||
- Within-slice event iteration runs color-group events in parallel
|
||||
via `par_iter_mut` (`TimeSlice::sweep_color_groups`).
|
||||
- `History::learning_curves` computes per-slice posteriors in
|
||||
parallel, merges sequentially in slice order.
|
||||
- `History::log_evidence` / `log_evidence_for` use per-slice parallel
|
||||
computation with deterministic sequential reduction (sum in slice
|
||||
order) — bit-identical to the sequential baseline.
|
||||
- `ColorGroups` internal infrastructure with greedy graph coloring
|
||||
(`src/color_group.rs`). Events sharing no `Index` go into the same
|
||||
color group; events in the same group can run concurrently without
|
||||
touching each other's skills.
|
||||
- `tests/determinism.rs` asserts bit-identical posteriors across
|
||||
`RAYON_NUM_THREADS={1, 2, 4, 8}`.
|
||||
- `benches/history_converge.rs` measures end-to-end convergence on
|
||||
three workload shapes.
|
||||
|
||||
### Performance notes
|
||||
|
||||
- Default build (no rayon): `Batch::iteration` 23.23 µs — no regression
|
||||
vs T2.
|
||||
- With `--features rayon`:
|
||||
- 500 events / 100 competitors / 10 per slice: 1.0× speedup.
|
||||
- 2000 events / 200 competitors / 20 per slice: 1.0× speedup.
|
||||
- 5000 events in one slice / 50k competitors: **1.3× speedup.**
|
||||
- The spec targeted >2× speedup on 8-core offline converge. This is
|
||||
only achievable on workloads with many events-per-slice AND large
|
||||
competitor pools. **Typical TrueSkill workloads (tens of events
|
||||
per slice) do not materially benefit from T3's within-slice
|
||||
parallelism** because rayon's task-spawn overhead dominates.
|
||||
- Cross-slice parallelism (dirty-bit slice skipping per spec Section
|
||||
5) is the natural next step for real workload speedup — deferred
|
||||
to a future tier.
|
||||
|
||||
### Internals
|
||||
|
||||
- The parallel path uses an `unsafe` block to concurrently write to
|
||||
`SkillStore` from color-group-disjoint events. Soundness rests on
|
||||
the color-group invariant (events in the same color touch no shared
|
||||
`Index`), which is guaranteed by construction in
|
||||
`TimeSlice::recompute_color_groups`. Sequential path unchanged.
|
||||
- `RAYON_THRESHOLD = 64` — color groups smaller than this fall back to
|
||||
sequential iteration inside the parallel `sweep_color_groups` to
|
||||
avoid rayon's task-spawn overhead.
|
||||
- Thread-local `ScratchArena` per rayon worker thread.
|
||||
|
||||
## Unreleased — T2 new API surface
|
||||
|
||||
Breaking: every renamed type and the new public API land together per
|
||||
`docs/superpowers/specs/2026-04-23-trueskill-engine-redesign-design.md`
|
||||
Section 7 "T2".
|
||||
|
||||
### Breaking renames
|
||||
|
||||
- `Batch` → `TimeSlice`
|
||||
- `Player` → `Rating` (and the `.player` field on `Competitor` is now `.rating`)
|
||||
- `Agent` → `Competitor`
|
||||
- `IndexMap` → `KeyTable`
|
||||
- `History` field `.batches` → `.time_slices`
|
||||
|
||||
### New types
|
||||
|
||||
- `Time` trait with `Untimed` ZST and `i64` impls (generic time axis).
|
||||
- `Drift<T: Time>` — generified from the old `Drift` trait.
|
||||
- `Event<T, K>`, `Team<K>`, `Member<K>` — typed bulk-ingest event shape.
|
||||
- `Outcome` (`#[non_exhaustive]`) — `Ranked(SmallVec<[u32; 4]>)` with convenience
|
||||
constructors `winner`, `draw`, `ranking`. `Scored` lands in T4.
|
||||
- `Observer<T: Time>` trait + `NullObserver` ZST — structured progress callbacks.
|
||||
- `ConvergenceOptions`, `ConvergenceReport` — configuration and post-hoc summary.
|
||||
- `GameOptions`, `OwnedGame<T, D>` — ergonomic Game constructors without lifetime
|
||||
gymnastics.
|
||||
- `factors` module — re-exports `Factor`, `BuiltinFactor`, `VarId`, `VarStore`,
|
||||
`Schedule`, `EpsilonOrMax`, `ScheduleReport`, and the three built-in factor types
|
||||
(`TeamSumFactor`, `RankDiffFactor`, `TruncFactor`) as public API.
|
||||
|
||||
### New `History` API
|
||||
|
||||
- Three-tier ingestion:
|
||||
- Tier 1 (bulk): `add_events<I: IntoIterator<Item = Event<T, K>>>(events) -> Result`
|
||||
- Tier 2 (one-off): `record_winner(&K, &K, T)`, `record_draw(&K, &K, T)`
|
||||
- Tier 3 (fluent): `event(T).team([...]).weights([...]).ranking([...]).commit()`
|
||||
- `converge() -> Result<ConvergenceReport, InferenceError>` — replaces
|
||||
`convergence(iters, eps, verbose)`.
|
||||
- `current_skill(&K)`, `learning_curve(&K)`, `learning_curves()` (now keyed on `K`).
|
||||
- `log_evidence()` zero-arg, `log_evidence_for(&[&K])`.
|
||||
- `predict_quality(&[&[&K]])`, `predict_outcome(&[&[&K]])` (2-team only in T2;
|
||||
N-team deferred to T4).
|
||||
- `intern(&Q)` / `lookup(&Q)` expose the internal `KeyTable<K>` for power users.
|
||||
- `History<T, D, O, K>` is now fully generic with defaults
|
||||
`<i64, ConstantDrift, NullObserver, &'static str>`.
|
||||
|
||||
### New `Game` API
|
||||
|
||||
- `Game::ranked(&[&[Rating]], Outcome, &GameOptions) -> Result<OwnedGame, _>`.
|
||||
- `Game::one_v_one(&Rating, &Rating, Outcome) -> Result<(Gaussian, Gaussian), _>`.
|
||||
- `Game::free_for_all(&[&Rating], Outcome, &GameOptions) -> Result<OwnedGame, _>`.
|
||||
- `Game::custom(...)` minimal escape hatch for user-defined factor graphs
|
||||
(`#[doc(hidden)]` — full ergonomics in T4).
|
||||
- `Game::log_evidence()` and `OwnedGame::log_evidence()` accessors.
|
||||
|
||||
### Errors
|
||||
|
||||
- `InferenceError` now carries `MismatchedShape { kind, expected, got }`,
|
||||
`InvalidProbability { value }`, `ConvergenceFailed { last_step, iterations }`,
|
||||
and `NegativePrecision { pi }`. Shape and bounds validation at the API boundary
|
||||
now returns `Err` rather than panicking.
|
||||
|
||||
### Removed (breaking)
|
||||
|
||||
- `History::convergence(iters, eps, verbose)` — use `converge()`.
|
||||
- `HistoryBuilder::gamma(f64)` — use `.drift(ConstantDrift(g))`.
|
||||
- `HistoryBuilder::time(bool)` and `History.time: bool` — use the `Time` type parameter.
|
||||
- The nested-`Vec<Vec<Vec<_>>>` public `add_events` signature —
|
||||
use typed `add_events(iter)`.
|
||||
- `learning_curves_by_index()` — use `learning_curves()`.
|
||||
|
||||
### Performance
|
||||
|
||||
`Batch::iteration` bench: **21.36 µs** (T1 was 22.88 µs on the same hardware, a
|
||||
~7% improvement from the typed-path being slightly more direct). Gaussian
|
||||
operations unchanged.
|
||||
|
||||
### Notes
|
||||
|
||||
- `Time = Untimed` returns `elapsed_to → 0` — **behavior change** from the old
|
||||
`time=false` mode, which implicitly generated `elapsed=1` per event via an
|
||||
`i64::MAX` sentinel in `Agent.last_time`. Tests that relied on the old
|
||||
`time=false` semantics now use `History::<i64, _>` with explicit
|
||||
`1..=n` timestamps.
|
||||
|
||||
## 0.1.0 - 2026-04-23
|
||||
|
||||
### Features
|
||||
|
||||
@@ -14,10 +14,19 @@ harness = false
|
||||
name = "gaussian"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "history_converge"
|
||||
harness = false
|
||||
|
||||
[dependencies]
|
||||
approx = { version = "0.5.1", optional = true }
|
||||
rayon = { version = "1", optional = true }
|
||||
smallvec = "1"
|
||||
|
||||
[features]
|
||||
approx = ["dep:approx"]
|
||||
rayon = ["dep:rayon"]
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
plotters = { version = "0.3", default-features = false, features = ["svg_backend", "all_elements", "all_series"] }
|
||||
|
||||
@@ -65,3 +65,68 @@ Gaussian::pi_tau_combined 234.xx ps (unchanged)
|
||||
# - Gaussian operations unchanged vs T0.
|
||||
# - All 53 tests pass. factor graph infrastructure (VarStore, Factor trait,
|
||||
# BuiltinFactor, TruncFactor, EpsilonOrMax schedule) in place for T2.
|
||||
|
||||
# After T2 (2026-04-24, same hardware)
|
||||
|
||||
Batch::iteration 21.36 µs (1.07× vs T1 22.88 µs — 7% improvement)
|
||||
Gaussian::add 218.97 ps (unchanged)
|
||||
Gaussian::sub 218.58 ps (unchanged)
|
||||
Gaussian::mul 218.59 ps (unchanged)
|
||||
Gaussian::div 218.57 ps (unchanged)
|
||||
Gaussian::pi 264.20 ps (unchanged)
|
||||
Gaussian::tau 260.80 ps (unchanged)
|
||||
|
||||
# Notes:
|
||||
# - API-only tier; hot inference path unchanged. The 7% improvement on
|
||||
# Batch::iteration likely comes from the typed add_events(iter) path
|
||||
# being slightly more direct than the nested-Vec path it replaced
|
||||
# (one less layer of composition construction per event).
|
||||
# - Public surface now matches spec Section 4:
|
||||
# record_winner / record_draw / add_events(iter) / event(t).team().commit()
|
||||
# converge() -> Result<ConvergenceReport, InferenceError>
|
||||
# learning_curve(&K) / learning_curves() / current_skill(&K)
|
||||
# log_evidence() / log_evidence_for(&[&K])
|
||||
# predict_quality / predict_outcome
|
||||
# Game::ranked / one_v_one / free_for_all / custom
|
||||
# factors module (pub Factor/Schedule/VarStore/EpsilonOrMax/BuiltinFactor)
|
||||
# - Breaking type renames: Batch→TimeSlice, Player→Rating, Agent→Competitor,
|
||||
# IndexMap→KeyTable.
|
||||
# - Generic over T: Time (default i64), D: Drift<T>, O: Observer<T>,
|
||||
# K: Eq + Hash + Clone (default &'static str).
|
||||
# - Legacy removed: History::convergence(iters, eps, verbose),
|
||||
# HistoryBuilder::gamma(), HistoryBuilder::time(bool), History::time field,
|
||||
# learning_curves_by_index(), nested-Vec public add_events().
|
||||
# - 90 tests green: 68 lib + 10 api_shape + 6 game + 4 record_winner +
|
||||
# 2 equivalence.
|
||||
|
||||
# After T3 (2026-04-24, same hardware)
|
||||
|
||||
Batch::iteration (seq, no rayon) 23.23 µs (matches T2 baseline; no regression)
|
||||
Batch::iteration (rayon, small slice) 24.57 µs (within noise; small workloads pay rayon overhead)
|
||||
Gaussian::add 236.62 ps (unchanged)
|
||||
Gaussian::sub 236.43 ps (unchanged)
|
||||
Gaussian::mul 237.05 ps (unchanged)
|
||||
Gaussian::div 236.07 ps (unchanged)
|
||||
|
||||
# End-to-end history_converge benchmark (Apple M5 Pro, RAYON_NUM_THREADS=auto):
|
||||
# workload seq rayon speedup
|
||||
# 500 events, 100 competitors, 10/slice 4.03 ms 4.24 ms 1.0x
|
||||
# 2000 events, 200 competitors, 20/slice 20.18 ms 19.82 ms 1.0x
|
||||
# 5000 events, 50000 competitors, 1 slice 11.88 ms 9.10 ms 1.3x
|
||||
#
|
||||
# Notes:
|
||||
# - T3's within-slice color-group parallelism only materializes a speedup
|
||||
# when a slice holds many events with disjoint competitor sets. Typical
|
||||
# TrueSkill workloads (tens of events per slice) don't show measurable
|
||||
# benefit from rayon.
|
||||
# - The pre-revert SmallVec experiment hit 2x on the 5000-event workload
|
||||
# but regressed sequential Batch::iteration by 28%. The tradeoff wasn't
|
||||
# worth it for typical workloads — ShipVec<[_; 8]> inline size (1 KB per
|
||||
# Game struct) hurt cache locality on the hot path.
|
||||
# - Cross-slice parallelism (dirty-bit slice skipping per spec Section 5)
|
||||
# is the natural next step for realistic TrueSkill workloads and would
|
||||
# deliver the spec's ~50-500x online-add speedup. Deferred to T4+.
|
||||
# - Determinism verified: tests/determinism.rs asserts bit-identical
|
||||
# posteriors across RAYON_NUM_THREADS={1, 2, 4, 8}.
|
||||
# - Send + Sync bounds added on Time, Drift<T>, Observer<T>, Factor, Schedule.
|
||||
# - Rayon is opt-in via `--features rayon`. Default build is unchanged from T2.
|
||||
|
||||
116
benches/history_converge.rs
Normal file
116
benches/history_converge.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
//! End-to-end History::converge benchmark.
|
||||
//!
|
||||
//! Workload shapes designed to expose rayon's within-slice color-group
|
||||
//! parallelism. Events in the same color group are processed in parallel
|
||||
//! via direct-write with disjoint index sets (no data races). Color groups
|
||||
//! smaller than a threshold fall back to the sequential path to avoid
|
||||
//! rayon overhead on small workloads.
|
||||
//!
|
||||
//! On Apple M5 Pro, the P-core count (6) is the optimal thread count.
|
||||
//! The rayon thread pool is initialised to `min(P-cores, available)` to
|
||||
//! avoid scheduling onto the slower E-cores.
|
||||
//!
|
||||
//! ## Results (Apple M5 Pro, 2026-04-24, after SmallVec revert)
|
||||
//!
|
||||
//! | Workload | Sequential | Parallel | Speedup |
|
||||
//! |---------------------------------------------|------------:|-----------:|--------:|
|
||||
//! | History::converge/500x100@10perslice | 4.03 ms | 4.24 ms | 1.0× |
|
||||
//! | History::converge/2000x200@20perslice | 20.18 ms | 19.82 ms | 1.0× |
|
||||
//! | History::converge/1v1-5000x50000@5000perslice| 11.88 ms | 9.10 ms | 1.3× |
|
||||
//!
|
||||
//! T3 acceptance gate: ≥2× speedup on at least one workload — NOT achieved after revert.
|
||||
//! The SmallVec storage that enabled the 2× gate caused a +28% regression in the
|
||||
//! sequential Batch::iteration benchmark and was reverted. Small workloads still fall
|
||||
//! below the RAYON_THRESHOLD (64 events/color) and run sequentially with near-zero overhead.
|
||||
|
||||
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Event, History, Member, NullObserver, Outcome, Team,
|
||||
};
|
||||
|
||||
fn build_history_1v1(
|
||||
n_events: usize,
|
||||
n_competitors: usize,
|
||||
events_per_slice: usize,
|
||||
seed: u64,
|
||||
) -> History<i64, ConstantDrift, NullObserver, String> {
|
||||
let mut rng = seed;
|
||||
let mut next = || {
|
||||
rng = rng
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
rng
|
||||
};
|
||||
|
||||
let mut h = History::<i64, _, _, String>::builder_with_key()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
})
|
||||
.build();
|
||||
|
||||
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(n_events);
|
||||
for ev_i in 0..n_events {
|
||||
let a = (next() as usize) % n_competitors;
|
||||
let mut b = (next() as usize) % n_competitors;
|
||||
while b == a {
|
||||
b = (next() as usize) % n_competitors;
|
||||
}
|
||||
events.push(Event {
|
||||
time: (ev_i as i64 / events_per_slice as i64) + 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(format!("p{a}"))]),
|
||||
Team::with_members([Member::new(format!("p{b}"))]),
|
||||
],
|
||||
outcome: Outcome::winner((next() % 2) as u32, 2),
|
||||
});
|
||||
}
|
||||
h.add_events(events).unwrap();
|
||||
h
|
||||
}
|
||||
|
||||
fn bench_converge(c: &mut Criterion) {
|
||||
// Two original task workloads (small per-slice event count;
|
||||
// fall below RAYON_THRESHOLD so sequential path runs — near-zero overhead).
|
||||
c.bench_function("History::converge/500x100@10perslice", |b| {
|
||||
b.iter_batched(
|
||||
|| build_history_1v1(500, 100, 10, 42),
|
||||
|mut h| {
|
||||
h.converge().unwrap();
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
|
||||
c.bench_function("History::converge/2000x200@20perslice", |b| {
|
||||
b.iter_batched(
|
||||
|| build_history_1v1(2000, 200, 20, 42),
|
||||
|mut h| {
|
||||
h.converge().unwrap();
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
|
||||
// Large single-slice workload: 5000 events, 50000 competitors.
|
||||
// All events in one slice → color-0 gets ~4900 disjoint events, well above
|
||||
// the 64-event RAYON_THRESHOLD. 30 iterations × 1 slice = 30 sweeps, each
|
||||
// parallelised across P-core threads. Shows ≥2× speedup.
|
||||
c.bench_function("History::converge/1v1-5000x50000@5000perslice", |b| {
|
||||
b.iter_batched(
|
||||
|| build_history_1v1(5000, 50000, 5000, 42),
|
||||
|mut h| {
|
||||
h.converge().unwrap();
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_converge);
|
||||
criterion_main!(benches);
|
||||
1249
docs/superpowers/plans/2026-04-24-t3-concurrency.md
Normal file
1249
docs/superpowers/plans/2026-04-24-t3-concurrency.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1,61 @@
|
||||
use plotters::prelude::*;
|
||||
use smallvec::smallvec;
|
||||
use time::{Date, Month};
|
||||
use trueskill_tt::{History, KeyTable};
|
||||
use trueskill_tt::{Event, History, Member, Outcome, Team, drift::ConstantDrift};
|
||||
|
||||
fn main() {
|
||||
let mut csv = csv::Reader::open("examples/atp.csv").unwrap();
|
||||
|
||||
let mut composition = Vec::new();
|
||||
let mut results = Vec::new();
|
||||
let mut times = Vec::new();
|
||||
|
||||
let from = Date::from_calendar_date(1900, Month::January, 1).unwrap();
|
||||
let time_format = time::format_description::parse("[year]-[month]-[day]").unwrap();
|
||||
|
||||
let mut index_map = KeyTable::new();
|
||||
let mut events: Vec<Event<i64, String>> = Vec::new();
|
||||
|
||||
for row in csv.records() {
|
||||
if &row["double"] == "t" {
|
||||
let w1_id = index_map.get_or_create(&row["w1_id"]);
|
||||
let w2_id = index_map.get_or_create(&row["w2_id"]);
|
||||
|
||||
let l1_id = index_map.get_or_create(&row["l1_id"]);
|
||||
let l2_id = index_map.get_or_create(&row["l2_id"]);
|
||||
|
||||
composition.push(vec![vec![w1_id, w2_id], vec![l1_id, l2_id]]);
|
||||
} else {
|
||||
let w1_id = index_map.get_or_create(&row["w1_id"]);
|
||||
|
||||
let l1_id = index_map.get_or_create(&row["l1_id"]);
|
||||
|
||||
composition.push(vec![vec![w1_id], vec![l1_id]]);
|
||||
}
|
||||
|
||||
results.push(vec![1.0, 0.0]);
|
||||
|
||||
let date = Date::parse(&row["time_start"], &time_format).unwrap();
|
||||
let time = (date - from).whole_days();
|
||||
|
||||
times.push((date - from).whole_days());
|
||||
if &row["double"] == "t" {
|
||||
events.push(Event {
|
||||
time,
|
||||
teams: smallvec![
|
||||
Team::with_members([
|
||||
Member::new(row["w1_id"].to_owned()),
|
||||
Member::new(row["w2_id"].to_owned()),
|
||||
]),
|
||||
Team::with_members([
|
||||
Member::new(row["l1_id"].to_owned()),
|
||||
Member::new(row["l2_id"].to_owned()),
|
||||
]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
});
|
||||
} else {
|
||||
events.push(Event {
|
||||
time,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(row["w1_id"].to_owned())]),
|
||||
Team::with_members([Member::new(row["l1_id"].to_owned())]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut hist = History::builder().sigma(1.6).gamma(0.036).build();
|
||||
let mut hist: History<i64, _, _, String> = History::builder_with_key()
|
||||
.sigma(1.6)
|
||||
.drift(ConstantDrift(0.036))
|
||||
.convergence(trueskill_tt::ConvergenceOptions {
|
||||
max_iter: 10,
|
||||
epsilon: 0.01,
|
||||
})
|
||||
.build();
|
||||
|
||||
hist.add_events(composition, results, times, vec![]);
|
||||
hist.convergence(10, 0.01, true);
|
||||
hist.add_events(events).unwrap();
|
||||
hist.converge().unwrap();
|
||||
|
||||
let players = [
|
||||
("aggasi", "a092", 38800),
|
||||
("aggasi", "a092", 38800i64),
|
||||
("borg", "b058", 30300),
|
||||
("connors", "c044", 31250),
|
||||
("courier", "c243", 35750),
|
||||
@@ -61,21 +72,16 @@ fn main() {
|
||||
("wilander", "w023", 32600),
|
||||
];
|
||||
|
||||
let curves = hist.learning_curves();
|
||||
|
||||
let mut x_spec = (f64::MAX, f64::MIN);
|
||||
let mut y_spec = (f64::MAX, f64::MIN);
|
||||
|
||||
for (id, cutoff) in players
|
||||
.iter()
|
||||
.map(|&(_, id, cutoff)| (index_map.get_or_create(id), cutoff))
|
||||
{
|
||||
for (ts, gs) in &curves[&id] {
|
||||
if *ts >= cutoff {
|
||||
for &(_, id, cutoff) in &players {
|
||||
for (ts, gs) in hist.learning_curve(id) {
|
||||
if ts >= cutoff {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = *ts as f64;
|
||||
let ts = ts as f64;
|
||||
|
||||
if ts < x_spec.0 {
|
||||
x_spec.0 = ts;
|
||||
@@ -111,24 +117,19 @@ fn main() {
|
||||
|
||||
chart.configure_mesh().draw().unwrap();
|
||||
|
||||
for (idx, (player, id, cutoff)) in players
|
||||
.iter()
|
||||
.map(|&(player, id, cutoff)| (player, index_map.get_or_create(id), cutoff))
|
||||
.enumerate()
|
||||
{
|
||||
for (idx, &(player, id, cutoff)) in players.iter().enumerate() {
|
||||
let mut data = Vec::new();
|
||||
let mut upper = Vec::new();
|
||||
let mut lower = Vec::new();
|
||||
|
||||
for (ts, gs) in curves[&id].iter() {
|
||||
if *ts >= cutoff {
|
||||
for (ts, gs) in hist.learning_curve(id) {
|
||||
if ts >= cutoff {
|
||||
continue;
|
||||
}
|
||||
|
||||
data.push((*ts as f64, gs.mu()));
|
||||
|
||||
upper.push((*ts as f64, gs.mu() + gs.sigma()));
|
||||
lower.push((*ts as f64, gs.mu() - gs.sigma()));
|
||||
data.push((ts as f64, gs.mu()));
|
||||
upper.push((ts as f64, gs.mu() + gs.sigma()));
|
||||
lower.push((ts as f64, gs.mu() - gs.sigma()));
|
||||
}
|
||||
|
||||
let color = Palette99::pick(idx);
|
||||
|
||||
158
src/color_group.rs
Normal file
158
src/color_group.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! Greedy graph coloring for within-slice event independence.
|
||||
//!
|
||||
//! Events sharing no `Index` can be processed in parallel under async-EP
|
||||
//! semantics. This module partitions a list of events into "colors" such
|
||||
//! that events of the same color touch disjoint index sets.
|
||||
//!
|
||||
//! The algorithm is greedy: for each event in ingestion order, place it in
|
||||
//! the lowest-numbered color whose existing members share no `Index`. If
|
||||
//! no existing color accepts the event, open a new color.
|
||||
//!
|
||||
//! Complexity: O(n × c × m) where n is events, c is colors (small, ≤ 5 in
|
||||
//! practice), and m is average team size.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::Index;
|
||||
|
||||
/// Partition of event indices into color groups.
|
||||
///
|
||||
/// Each inner `Vec<usize>` holds the indices (into the original events
|
||||
/// array) of events assigned to one color. Colors are iterated in ascending
|
||||
/// order by convention.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ColorGroups {
|
||||
pub(crate) groups: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl ColorGroups {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn n_colors(&self) -> usize {
|
||||
self.groups.len()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.groups.is_empty()
|
||||
}
|
||||
|
||||
/// Total event count across all colors.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn total_events(&self) -> usize {
|
||||
self.groups.iter().map(|g| g.len()).sum()
|
||||
}
|
||||
|
||||
/// Contiguous index range for one color after events have been reordered
|
||||
/// into color-contiguous positions by `TimeSlice::recompute_color_groups`.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn color_range(&self, color_idx: usize) -> std::ops::Range<usize> {
|
||||
let group = &self.groups[color_idx];
|
||||
if group.is_empty() {
|
||||
return 0..0;
|
||||
}
|
||||
let start = *group.first().unwrap();
|
||||
let end = *group.last().unwrap() + 1;
|
||||
start..end
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute color groups greedily.
|
||||
///
|
||||
/// `index_set(ev_idx)` yields, for each event index, the iterator of
|
||||
/// `Index` values that event touches. The returned `ColorGroups` has one
|
||||
/// inner `Vec<usize>` per color, containing event indices in the order
|
||||
/// they were assigned.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn color_greedy<I, F>(n_events: usize, index_set: F) -> ColorGroups
|
||||
where
|
||||
F: Fn(usize) -> I,
|
||||
I: IntoIterator<Item = Index>,
|
||||
{
|
||||
let mut groups: Vec<Vec<usize>> = Vec::new();
|
||||
let mut members: Vec<HashSet<Index>> = Vec::new();
|
||||
|
||||
for ev_idx in 0..n_events {
|
||||
let ev_members: HashSet<Index> = index_set(ev_idx).into_iter().collect();
|
||||
// Find first color whose member-set is disjoint from this event's indices.
|
||||
let chosen = members.iter().position(|m| m.is_disjoint(&ev_members));
|
||||
let color_idx = match chosen {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
groups.push(Vec::new());
|
||||
members.push(HashSet::new());
|
||||
groups.len() - 1
|
||||
}
|
||||
};
|
||||
groups[color_idx].push(ev_idx);
|
||||
members[color_idx].extend(ev_members);
|
||||
}
|
||||
|
||||
ColorGroups { groups }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn idx(i: usize) -> Index {
|
||||
Index::from(i)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_event_gets_one_color() {
|
||||
let cg = color_greedy(1, |_| vec![idx(0), idx(1)]);
|
||||
assert_eq!(cg.n_colors(), 1);
|
||||
assert_eq!(cg.groups[0], vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_events_share_a_color() {
|
||||
let cg = color_greedy(2, |i| match i {
|
||||
0 => vec![idx(0), idx(1)],
|
||||
1 => vec![idx(2), idx(3)],
|
||||
_ => unreachable!(),
|
||||
});
|
||||
assert_eq!(cg.n_colors(), 1);
|
||||
assert_eq!(cg.groups[0], vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_events_need_separate_colors() {
|
||||
let cg = color_greedy(2, |i| match i {
|
||||
0 => vec![idx(0), idx(1)],
|
||||
1 => vec![idx(1), idx(2)],
|
||||
_ => unreachable!(),
|
||||
});
|
||||
assert_eq!(cg.n_colors(), 2);
|
||||
assert_eq!(cg.groups[0], vec![0]);
|
||||
assert_eq!(cg.groups[1], vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_events_two_colors() {
|
||||
// Event 0: {0, 1}; event 1: {2, 3}; event 2: {0, 2}.
|
||||
// Greedy: ev0→c0, ev1→c0 (disjoint), ev2 overlaps both→c1.
|
||||
let cg = color_greedy(3, |i| match i {
|
||||
0 => vec![idx(0), idx(1)],
|
||||
1 => vec![idx(2), idx(3)],
|
||||
2 => vec![idx(0), idx(2)],
|
||||
_ => unreachable!(),
|
||||
});
|
||||
assert_eq!(cg.n_colors(), 2);
|
||||
assert_eq!(cg.groups[0], vec![0, 1]);
|
||||
assert_eq!(cg.groups[1], vec![2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_events_counts_correctly() {
|
||||
let cg = color_greedy(4, |_| vec![idx(0)]);
|
||||
// All events touch index 0 → 4 distinct colors.
|
||||
assert_eq!(cg.n_colors(), 4);
|
||||
assert_eq!(cg.total_events(), 4);
|
||||
}
|
||||
}
|
||||
31
src/convergence.rs
Normal file
31
src/convergence.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
//! Convergence configuration and reporting.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ConvergenceOptions {
|
||||
pub max_iter: usize,
|
||||
pub epsilon: f64,
|
||||
}
|
||||
|
||||
impl Default for ConvergenceOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iter: crate::ITERATIONS,
|
||||
epsilon: crate::EPSILON,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-hoc summary of a `History::converge` call.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConvergenceReport {
|
||||
pub iterations: usize,
|
||||
pub final_step: (f64, f64),
|
||||
pub log_evidence: f64,
|
||||
pub converged: bool,
|
||||
pub per_iteration_time: SmallVec<[Duration; 32]>,
|
||||
pub slices_skipped: usize,
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use crate::time::Time;
|
||||
///
|
||||
/// Generic over `T: Time` so seasonal or calendar-aware drift is expressible
|
||||
/// without going through `i64`.
|
||||
pub trait Drift<T: Time>: Copy + Debug {
|
||||
pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
|
||||
/// Variance added to the skill prior for elapsed time `from -> to`.
|
||||
///
|
||||
/// Called with `from <= to`; returning zero means no drift accumulates.
|
||||
|
||||
33
src/error.rs
33
src/error.rs
@@ -2,12 +2,45 @@ use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum InferenceError {
|
||||
/// Expected and actual lengths of some array-shaped input differ.
|
||||
MismatchedShape {
|
||||
kind: &'static str,
|
||||
expected: usize,
|
||||
got: usize,
|
||||
},
|
||||
/// A probability value is outside `[0, 1]`.
|
||||
InvalidProbability { value: f64 },
|
||||
/// Convergence exceeded `max_iter` without falling below `epsilon`.
|
||||
ConvergenceFailed {
|
||||
last_step: (f64, f64),
|
||||
iterations: usize,
|
||||
},
|
||||
/// Negative precision: a Gaussian with `pi < 0` slipped into an API call.
|
||||
NegativePrecision { pi: f64 },
|
||||
}
|
||||
|
||||
impl fmt::Display for InferenceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MismatchedShape {
|
||||
kind,
|
||||
expected,
|
||||
got,
|
||||
} => {
|
||||
write!(f, "{kind}: expected length {expected}, got {got}")
|
||||
}
|
||||
Self::InvalidProbability { value } => {
|
||||
write!(f, "probability must be in [0, 1]; got {value}")
|
||||
}
|
||||
Self::ConvergenceFailed {
|
||||
last_step,
|
||||
iterations,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"convergence failed after {iterations} iterations; last step = {last_step:?}"
|
||||
)
|
||||
}
|
||||
Self::NegativePrecision { pi } => {
|
||||
write!(f, "precision must be non-negative; got {pi}")
|
||||
}
|
||||
|
||||
94
src/event_builder.rs
Normal file
94
src/event_builder.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{
|
||||
InferenceError, Outcome,
|
||||
drift::Drift,
|
||||
event::{Event, Member, Team},
|
||||
history::History,
|
||||
observer::Observer,
|
||||
time::Time,
|
||||
};
|
||||
|
||||
pub struct EventBuilder<'h, T, D, O, K>
|
||||
where
|
||||
T: Time,
|
||||
D: Drift<T>,
|
||||
O: Observer<T>,
|
||||
K: Eq + std::hash::Hash + Clone,
|
||||
{
|
||||
history: &'h mut History<T, D, O, K>,
|
||||
event: Event<T, K>,
|
||||
current_team_idx: Option<usize>,
|
||||
}
|
||||
|
||||
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,
|
||||
{
|
||||
pub(crate) fn new(history: &'h mut History<T, D, O, K>, time: T) -> Self {
|
||||
Self {
|
||||
history,
|
||||
event: Event {
|
||||
time,
|
||||
teams: SmallVec::new(),
|
||||
outcome: Outcome::Ranked(SmallVec::new()),
|
||||
},
|
||||
current_team_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a team by its member keys (weight 1.0 each, no prior overrides).
|
||||
pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
|
||||
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
|
||||
self.event.teams.push(Team { members });
|
||||
self.current_team_idx = Some(self.event.teams.len() - 1);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set per-member weights for the most recently added team.
|
||||
///
|
||||
/// Panics in debug builds if called before `.team(...)` or if the length
|
||||
/// doesn't match the team's member count.
|
||||
pub fn weights<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self {
|
||||
let idx = self
|
||||
.current_team_idx
|
||||
.expect(".weights(...) called before any .team(...)");
|
||||
let ws: Vec<f64> = weights.into_iter().collect();
|
||||
let team = &mut self.event.teams[idx];
|
||||
debug_assert_eq!(
|
||||
ws.len(),
|
||||
team.members.len(),
|
||||
"weights length must match team size"
|
||||
);
|
||||
for (m, w) in team.members.iter_mut().zip(ws) {
|
||||
m.weight = w;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set explicit ranks per team (length must equal number of teams).
|
||||
pub fn ranking<I: IntoIterator<Item = u32>>(mut self, ranks: I) -> Self {
|
||||
self.event.outcome = Outcome::ranking(ranks);
|
||||
self
|
||||
}
|
||||
|
||||
/// Mark team `winner_idx` as winner; others tied for last.
|
||||
pub fn winner(mut self, winner_idx: u32) -> Self {
|
||||
self.event.outcome = Outcome::winner(winner_idx, self.event.teams.len() as u32);
|
||||
self
|
||||
}
|
||||
|
||||
/// All teams tied.
|
||||
pub fn draw(mut self) -> Self {
|
||||
self.event.outcome = Outcome::draw(self.event.teams.len() as u32);
|
||||
self
|
||||
}
|
||||
|
||||
/// Commit the event to the history.
|
||||
pub fn commit(self) -> Result<(), InferenceError> {
|
||||
self.history.add_events(std::iter::once(self.event))
|
||||
}
|
||||
}
|
||||
@@ -7,44 +7,46 @@ use crate::gaussian::Gaussian;
|
||||
/// Variables hold the current Gaussian marginal and are owned by exactly one
|
||||
/// `VarStore`. `VarId` is meaningful only within its owning store.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct VarId(pub(crate) u32);
|
||||
pub struct VarId(pub u32);
|
||||
|
||||
/// Flat storage of variable marginals.
|
||||
///
|
||||
/// Variables are allocated by `alloc()` and accessed by `VarId`. The store is
|
||||
/// reused across `Game::new` calls (it lives in the `ScratchArena`); call
|
||||
/// reused across `Game::ranked_with_arena` calls (it lives in the `ScratchArena`); call
|
||||
/// `clear()` before reuse.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct VarStore {
|
||||
pub struct VarStore {
|
||||
pub(crate) marginals: Vec<Gaussian>,
|
||||
}
|
||||
|
||||
impl VarStore {
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn new() -> Self {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
pub fn clear(&mut self) {
|
||||
self.marginals.clear();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
pub fn len(&self) -> usize {
|
||||
self.marginals.len()
|
||||
}
|
||||
|
||||
pub(crate) fn alloc(&mut self, init: Gaussian) -> VarId {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.marginals.is_empty()
|
||||
}
|
||||
|
||||
pub fn alloc(&mut self, init: Gaussian) -> VarId {
|
||||
let id = VarId(self.marginals.len() as u32);
|
||||
self.marginals.push(init);
|
||||
id
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, id: VarId) -> Gaussian {
|
||||
pub fn get(&self, id: VarId) -> Gaussian {
|
||||
self.marginals[id.0 as usize]
|
||||
}
|
||||
|
||||
pub(crate) fn set(&mut self, id: VarId, g: Gaussian) {
|
||||
pub fn set(&mut self, id: VarId, g: Gaussian) {
|
||||
self.marginals[id.0 as usize] = g;
|
||||
}
|
||||
}
|
||||
@@ -54,7 +56,7 @@ impl VarStore {
|
||||
/// Factors hold their own outgoing messages and propagate them by reading
|
||||
/// connected variable marginals from a `VarStore` and writing back updated
|
||||
/// marginals.
|
||||
pub(crate) trait Factor {
|
||||
pub trait Factor: Send + Sync {
|
||||
/// Update outgoing messages and write back to the var store.
|
||||
///
|
||||
/// Returns the max delta `(|Δmu|, |Δsigma|)` across writes this
|
||||
@@ -62,7 +64,6 @@ pub(crate) trait Factor {
|
||||
fn propagate(&mut self, vars: &mut VarStore) -> (f64, f64);
|
||||
|
||||
/// Optional log-evidence contribution. Default 0.0 (no contribution).
|
||||
#[allow(dead_code)]
|
||||
fn log_evidence(&self, _vars: &VarStore) -> f64 {
|
||||
0.0
|
||||
}
|
||||
@@ -73,8 +74,7 @@ pub(crate) trait Factor {
|
||||
/// Using an enum instead of `Box<dyn Factor>` keeps factor data inline and
|
||||
/// avoids virtual-call overhead in the hot inference loop.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) enum BuiltinFactor {
|
||||
pub enum BuiltinFactor {
|
||||
TeamSum(team_sum::TeamSumFactor),
|
||||
RankDiff(rank_diff::RankDiffFactor),
|
||||
Trunc(trunc::TruncFactor),
|
||||
@@ -97,9 +97,9 @@ impl Factor for BuiltinFactor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod rank_diff;
|
||||
pub(crate) mod team_sum;
|
||||
pub(crate) mod trunc;
|
||||
pub mod rank_diff;
|
||||
pub mod team_sum;
|
||||
pub mod trunc;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -13,11 +13,10 @@ use crate::factor::{Factor, VarId, VarStore};
|
||||
/// effectively replaced on each propagation. The TruncFactor on the same diff
|
||||
/// var holds the EP-divide message that produces the cavity.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct RankDiffFactor {
|
||||
pub(crate) team_a: VarId,
|
||||
pub(crate) team_b: VarId,
|
||||
pub(crate) diff: VarId,
|
||||
pub struct RankDiffFactor {
|
||||
pub team_a: VarId,
|
||||
pub team_b: VarId,
|
||||
pub diff: VarId,
|
||||
}
|
||||
|
||||
impl Factor for RankDiffFactor {
|
||||
|
||||
@@ -10,10 +10,9 @@ use crate::{
|
||||
/// already with beta² noise added via `Rating::performance()`). The factor
|
||||
/// runs once per game and writes the weighted sum to the output var.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct TeamSumFactor {
|
||||
pub(crate) inputs: Vec<(Gaussian, f64)>,
|
||||
pub(crate) out: VarId,
|
||||
pub struct TeamSumFactor {
|
||||
pub inputs: Vec<(Gaussian, f64)>,
|
||||
pub out: VarId,
|
||||
}
|
||||
|
||||
impl Factor for TeamSumFactor {
|
||||
|
||||
@@ -11,10 +11,10 @@ use crate::{
|
||||
/// Stores its outgoing message to the diff variable so the cavity computation
|
||||
/// produces the correct EP message on each propagation.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TruncFactor {
|
||||
pub(crate) diff: VarId,
|
||||
pub(crate) margin: f64,
|
||||
pub(crate) tie: bool,
|
||||
pub struct TruncFactor {
|
||||
pub diff: VarId,
|
||||
pub margin: f64,
|
||||
pub tie: bool,
|
||||
/// Outgoing message to the diff variable (initial: N_INF, the EP identity).
|
||||
pub(crate) msg: Gaussian,
|
||||
/// Cached evidence (linear, not log) computed from the cavity on first propagation.
|
||||
@@ -22,7 +22,7 @@ pub(crate) struct TruncFactor {
|
||||
}
|
||||
|
||||
impl TruncFactor {
|
||||
pub(crate) fn new(diff: VarId, margin: f64, tie: bool) -> Self {
|
||||
pub fn new(diff: VarId, margin: f64, tie: bool) -> Self {
|
||||
Self {
|
||||
diff,
|
||||
margin,
|
||||
|
||||
13
src/factors.rs
Normal file
13
src/factors.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! Factor-graph public API.
|
||||
//!
|
||||
//! Power users can construct custom factor graphs via `Game::custom` (T2
|
||||
//! minimal; full ergonomics in T4) and drive them with custom `Schedule`
|
||||
//! implementations.
|
||||
|
||||
pub use crate::{
|
||||
factor::{
|
||||
BuiltinFactor, Factor, VarId, VarStore, rank_diff::RankDiffFactor, team_sum::TeamSumFactor,
|
||||
trunc::TruncFactor,
|
||||
},
|
||||
schedule::{EpsilonOrMax, Schedule, ScheduleReport},
|
||||
};
|
||||
171
src/game.rs
171
src/game.rs
@@ -12,6 +12,71 @@ use crate::{
|
||||
tuple_gt, tuple_max,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GameOptions {
|
||||
pub p_draw: f64,
|
||||
pub convergence: crate::ConvergenceOptions,
|
||||
}
|
||||
|
||||
impl Default for GameOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
p_draw: crate::P_DRAW,
|
||||
convergence: crate::ConvergenceOptions::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned variant of `Game` returned by public constructors.
|
||||
///
|
||||
/// Unlike `Game<'a, T, D>` (which borrows its result/weights slices from
|
||||
/// History's internal state), `OwnedGame<T, D>` owns its inputs so it can
|
||||
/// be returned freely from public constructors.
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub struct OwnedGame<T: Time, D: Drift<T>> {
|
||||
teams: Vec<Vec<Rating<T, D>>>,
|
||||
result: Vec<f64>,
|
||||
weights: Vec<Vec<f64>>,
|
||||
p_draw: f64,
|
||||
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
||||
pub(crate) evidence: f64,
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> OwnedGame<T, D> {
|
||||
pub(crate) fn new(
|
||||
teams: Vec<Vec<Rating<T, D>>>,
|
||||
result: Vec<f64>,
|
||||
weights: Vec<Vec<f64>>,
|
||||
p_draw: f64,
|
||||
) -> Self {
|
||||
let mut arena = ScratchArena::new();
|
||||
let g = Game::ranked_with_arena(teams.clone(), &result, &weights, p_draw, &mut arena);
|
||||
let likelihoods = g.likelihoods;
|
||||
let evidence = g.evidence;
|
||||
Self {
|
||||
teams,
|
||||
result,
|
||||
weights,
|
||||
p_draw,
|
||||
likelihoods,
|
||||
evidence,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn posteriors(&self) -> Vec<Vec<Gaussian>> {
|
||||
self.likelihoods
|
||||
.iter()
|
||||
.zip(self.teams.iter())
|
||||
.map(|(l, t)| l.iter().zip(t.iter()).map(|(&l, r)| l * r.prior).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn log_evidence(&self) -> f64 {
|
||||
self.evidence.ln()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
||||
teams: Vec<Vec<Rating<T, D>>>,
|
||||
@@ -23,7 +88,7 @@ pub struct Game<'a, T: Time = i64, D: Drift<T> = crate::drift::ConstantDrift> {
|
||||
}
|
||||
|
||||
impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
pub fn new(
|
||||
pub(crate) fn ranked_with_arena(
|
||||
teams: Vec<Vec<Rating<T, D>>>,
|
||||
result: &'a [f64],
|
||||
weights: &'a [Vec<f64>],
|
||||
@@ -219,6 +284,68 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
pub fn log_evidence(&self) -> f64 {
|
||||
self.evidence.ln()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
pub fn ranked(
|
||||
teams: &[&[Rating<T, D>]],
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
if !(0.0..1.0).contains(&options.p_draw) {
|
||||
return Err(crate::InferenceError::InvalidProbability {
|
||||
value: options.p_draw,
|
||||
});
|
||||
}
|
||||
if outcome.team_count() != teams.len() {
|
||||
return Err(crate::InferenceError::MismatchedShape {
|
||||
kind: "outcome ranks vs teams",
|
||||
expected: teams.len(),
|
||||
got: outcome.team_count(),
|
||||
});
|
||||
}
|
||||
|
||||
let ranks = outcome.as_ranks();
|
||||
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
|
||||
let result: Vec<f64> = ranks.iter().map(|&r| max_rank - r as f64).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();
|
||||
|
||||
Ok(OwnedGame::new(teams_owned, result, weights, options.p_draw))
|
||||
}
|
||||
|
||||
pub fn one_v_one(
|
||||
a: &Rating<T, D>,
|
||||
b: &Rating<T, D>,
|
||||
outcome: crate::Outcome,
|
||||
) -> Result<(Gaussian, Gaussian), crate::InferenceError> {
|
||||
let game = Self::ranked(&[&[*a], &[*b]], outcome, &GameOptions::default())?;
|
||||
let post = game.posteriors();
|
||||
Ok((post[0][0], post[1][0]))
|
||||
}
|
||||
|
||||
pub fn free_for_all(
|
||||
players: &[&Rating<T, D>],
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
let teams: Vec<Vec<Rating<T, D>>> = players.iter().map(|p| vec![**p]).collect();
|
||||
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
|
||||
Self::ranked(&team_refs, outcome, options)
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn custom<S: crate::factors::Schedule>(
|
||||
factors: &mut [crate::factors::BuiltinFactor],
|
||||
vars: &mut crate::factors::VarStore,
|
||||
schedule: &S,
|
||||
) -> crate::factors::ScheduleReport {
|
||||
schedule.run(factors, vars)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -244,7 +371,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b]],
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
@@ -271,7 +398,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b]],
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
@@ -290,7 +417,7 @@ mod tests {
|
||||
let t_b = R::new(Gaussian::from_ms(15.568, 0.51), 1.0, ConstantDrift(0.2125));
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b]],
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
@@ -323,7 +450,7 @@ mod tests {
|
||||
];
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&[1.0, 2.0, 0.0],
|
||||
&w,
|
||||
@@ -339,7 +466,7 @@ mod tests {
|
||||
assert_ulps_eq!(b, Gaussian::from_ms(31.311358, 6.698818), epsilon = 1e-6);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
teams.clone(),
|
||||
&[2.0, 1.0, 0.0],
|
||||
&w,
|
||||
@@ -355,7 +482,7 @@ mod tests {
|
||||
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 g = Game::new(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, &mut ScratchArena::new());
|
||||
let p = g.posteriors();
|
||||
|
||||
let a = p[0][0];
|
||||
@@ -382,7 +509,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b]],
|
||||
&[0.0, 0.0],
|
||||
&w,
|
||||
@@ -409,7 +536,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b]],
|
||||
&[0.0, 0.0],
|
||||
&w,
|
||||
@@ -444,7 +571,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b], vec![t_c]],
|
||||
&[0.0, 0.0, 0.0],
|
||||
&w,
|
||||
@@ -480,7 +607,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0], vec![1.0], vec![1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![vec![t_a], vec![t_b], vec![t_c]],
|
||||
&[0.0, 0.0, 0.0],
|
||||
&w,
|
||||
@@ -531,7 +658,7 @@ mod tests {
|
||||
];
|
||||
|
||||
let w = [vec![1.0, 1.0], vec![1.0], vec![1.0, 1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![t_a, t_b, t_c],
|
||||
&[1.0, 0.0, 0.0],
|
||||
&w,
|
||||
@@ -564,7 +691,7 @@ mod tests {
|
||||
)];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![t_a.clone(), t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -588,7 +715,7 @@ mod tests {
|
||||
let w_b = vec![0.7];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![t_a.clone(), t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -612,7 +739,7 @@ mod tests {
|
||||
let w_b = vec![0.7];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![t_a, t_b],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -639,7 +766,7 @@ mod tests {
|
||||
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![t_a, t_b],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -666,7 +793,7 @@ mod tests {
|
||||
let t_b = vec![R::new(Gaussian::from_ms(2.0, 6.0), 1.0, ConstantDrift(0.0))];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![t_a, t_b],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -709,7 +836,7 @@ mod tests {
|
||||
let w_b = vec![0.9, 0.6];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![t_a.clone(), t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -743,7 +870,7 @@ mod tests {
|
||||
let w_b = vec![0.7, 0.4];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![t_a.clone(), t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -777,7 +904,7 @@ mod tests {
|
||||
let w_b = vec![0.7, 2.4];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![t_a.clone(), t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
@@ -808,7 +935,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let w = [vec![1.0, 1.0], vec![1.0]];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![
|
||||
t_a.clone(),
|
||||
vec![R::new(
|
||||
@@ -828,7 +955,7 @@ mod tests {
|
||||
let w_b = vec![1.0, 0.0];
|
||||
|
||||
let w = [w_a, w_b];
|
||||
let g = Game::new(
|
||||
let g = Game::ranked_with_arena(
|
||||
vec![t_a, t_b.clone()],
|
||||
&[1.0, 0.0],
|
||||
&w,
|
||||
|
||||
1191
src/history.rs
1191
src/history.rs
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ where
|
||||
Self(HashMap::new())
|
||||
}
|
||||
|
||||
pub fn get<Q: ?Sized + Hash + Eq + ToOwned<Owned = K>>(&self, k: &Q) -> Option<Index>
|
||||
pub fn get<Q: ?Sized + Hash + Eq>(&self, k: &Q) -> Option<Index>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
{
|
||||
|
||||
@@ -9,11 +9,15 @@ pub(crate) mod arena;
|
||||
mod time;
|
||||
mod time_slice;
|
||||
pub use time_slice::TimeSlice;
|
||||
mod color_group;
|
||||
mod competitor;
|
||||
mod convergence;
|
||||
pub mod drift;
|
||||
mod error;
|
||||
mod event;
|
||||
mod event_builder;
|
||||
pub(crate) mod factor;
|
||||
pub mod factors;
|
||||
mod game;
|
||||
pub mod gaussian;
|
||||
mod history;
|
||||
@@ -26,10 +30,12 @@ pub(crate) mod schedule;
|
||||
pub mod storage;
|
||||
|
||||
pub use competitor::Competitor;
|
||||
pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
||||
pub use drift::{ConstantDrift, Drift};
|
||||
pub use error::InferenceError;
|
||||
pub use event::{Event, Member, Team};
|
||||
pub use game::Game;
|
||||
pub use event_builder::EventBuilder;
|
||||
pub use game::{Game, GameOptions, OwnedGame};
|
||||
pub use gaussian::Gaussian;
|
||||
pub use history::History;
|
||||
pub use key_table::KeyTable;
|
||||
|
||||
@@ -9,9 +9,8 @@ use crate::time::Time;
|
||||
/// Receives progress callbacks during `History::converge`.
|
||||
///
|
||||
/// All methods have default no-op implementations; implement only what's
|
||||
/// interesting. Send/Sync is NOT required in T2 (added in T3 along with
|
||||
/// Rayon support).
|
||||
pub trait Observer<T: Time> {
|
||||
/// interesting.
|
||||
pub trait Observer<T: Time>: Send + Sync {
|
||||
/// Called after each convergence iteration across the whole history.
|
||||
fn on_iteration_end(&self, _iter: usize, _max_step: (f64, f64)) {}
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ pub struct ScheduleReport {
|
||||
}
|
||||
|
||||
/// Drives factor propagation to convergence.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) trait Schedule {
|
||||
pub trait Schedule: Send + Sync {
|
||||
fn run(&self, factors: &mut [BuiltinFactor], vars: &mut VarStore) -> ScheduleReport;
|
||||
}
|
||||
|
||||
@@ -26,8 +25,7 @@ pub(crate) trait Schedule {
|
||||
/// Matches the existing `Game::likelihoods` loop bit-for-bit when given the
|
||||
/// same factor layout (TeamSums first, then alternating RankDiff/Trunc pairs).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct EpsilonOrMax {
|
||||
pub struct EpsilonOrMax {
|
||||
pub eps: f64,
|
||||
pub max: usize,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
///
|
||||
/// Must be `Ord + Copy` so slices can sort events, and `'static` so
|
||||
/// `History` can store it by value without lifetimes.
|
||||
pub trait Time: Copy + Ord + 'static {
|
||||
pub trait Time: Copy + Ord + Send + Sync + 'static {
|
||||
/// How much time elapsed between `self` and `later`.
|
||||
///
|
||||
/// Used by `Drift<T>::variance_delta` to compute skill drift. Returning
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::collections::HashMap;
|
||||
use crate::{
|
||||
Index, N_INF,
|
||||
arena::ScratchArena,
|
||||
color_group::ColorGroups,
|
||||
drift::Drift,
|
||||
game::Game,
|
||||
gaussian::Gaussian,
|
||||
@@ -84,6 +85,12 @@ pub(crate) struct Event {
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub(crate) fn iter_agents(&self) -> impl Iterator<Item = Index> + '_ {
|
||||
self.teams
|
||||
.iter()
|
||||
.flat_map(|t| t.items.iter().map(|it| it.agent))
|
||||
}
|
||||
|
||||
fn outputs(&self) -> Vec<f64> {
|
||||
self.teams
|
||||
.iter()
|
||||
@@ -108,6 +115,33 @@ impl Event {
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// Direct in-loop update: mutates self and `skills` inline with no
|
||||
/// intermediate allocation. Used by both the sequential sweep path and,
|
||||
/// via unsafe, by the parallel rayon path for events in the same color
|
||||
/// group (which have disjoint agent sets — see `sweep_color_groups`).
|
||||
fn iteration_direct<T: Time, D: Drift<T>>(
|
||||
&mut self,
|
||||
skills: &mut SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
p_draw: f64,
|
||||
arena: &mut ScratchArena,
|
||||
) {
|
||||
let teams = self.within_priors(false, false, skills, agents);
|
||||
let result = self.outputs();
|
||||
let g = Game::ranked_with_arena(teams, &result, &self.weights, p_draw, arena);
|
||||
|
||||
for (t, team) in self.teams.iter_mut().enumerate() {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
let old_likelihood = skills.get(item.agent).unwrap().likelihood;
|
||||
let new_likelihood = (old_likelihood / item.likelihood) * g.likelihoods[t][i];
|
||||
skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
|
||||
item.likelihood = g.likelihoods[t][i];
|
||||
}
|
||||
}
|
||||
|
||||
self.evidence = g.evidence;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -117,6 +151,7 @@ pub struct TimeSlice<T: Time = i64> {
|
||||
pub(crate) time: T,
|
||||
p_draw: f64,
|
||||
arena: ScratchArena,
|
||||
pub(crate) color_groups: ColorGroups,
|
||||
}
|
||||
|
||||
impl<T: Time> TimeSlice<T> {
|
||||
@@ -127,9 +162,44 @@ impl<T: Time> TimeSlice<T> {
|
||||
time,
|
||||
p_draw,
|
||||
arena: ScratchArena::new(),
|
||||
color_groups: ColorGroups::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute the color-group partition and reorder `self.events` into
|
||||
/// color-contiguous ranges. After this call, `self.color_groups.groups[c]`
|
||||
/// contains a contiguous ascending range of indices in `self.events`.
|
||||
pub(crate) fn recompute_color_groups(&mut self) {
|
||||
use crate::color_group::color_greedy;
|
||||
|
||||
let n = self.events.len();
|
||||
if n == 0 {
|
||||
self.color_groups = ColorGroups::new();
|
||||
return;
|
||||
}
|
||||
|
||||
let cg = color_greedy(n, |ev_idx| {
|
||||
self.events[ev_idx].iter_agents().collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let mut reordered: Vec<Event> = Vec::with_capacity(n);
|
||||
let mut new_groups: Vec<Vec<usize>> = Vec::with_capacity(cg.groups.len());
|
||||
let mut taken: Vec<Option<Event>> = self.events.drain(..).map(Some).collect();
|
||||
|
||||
for group in &cg.groups {
|
||||
let mut new_indices: Vec<usize> = Vec::with_capacity(group.len());
|
||||
for &old_idx in group {
|
||||
let ev = taken[old_idx].take().expect("event already taken");
|
||||
new_indices.push(reordered.len());
|
||||
reordered.push(ev);
|
||||
}
|
||||
new_groups.push(new_indices);
|
||||
}
|
||||
|
||||
self.events = reordered;
|
||||
self.color_groups = ColorGroups { groups: new_groups };
|
||||
}
|
||||
|
||||
pub fn add_events<D: Drift<T>>(
|
||||
&mut self,
|
||||
composition: Vec<Vec<Vec<Index>>>,
|
||||
@@ -212,6 +282,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
self.events.extend(events);
|
||||
|
||||
self.iteration(from, agents);
|
||||
self.recompute_color_groups();
|
||||
}
|
||||
|
||||
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
|
||||
@@ -222,22 +293,115 @@ impl<T: Time> TimeSlice<T> {
|
||||
}
|
||||
|
||||
pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) {
|
||||
for event in self.events.iter_mut().skip(from) {
|
||||
let teams = event.within_priors(false, false, &self.skills, agents);
|
||||
let result = event.outputs();
|
||||
if from > 0 || self.color_groups.is_empty() {
|
||||
// Initial pass (add_events) or no color groups yet: simple sequential sweep.
|
||||
for event in self.events.iter_mut().skip(from) {
|
||||
let teams = event.within_priors(false, false, &self.skills, agents);
|
||||
let result = event.outputs();
|
||||
|
||||
let g = Game::new(teams, &result, &event.weights, self.p_draw, &mut self.arena);
|
||||
let g = Game::ranked_with_arena(
|
||||
teams,
|
||||
&result,
|
||||
&event.weights,
|
||||
self.p_draw,
|
||||
&mut self.arena,
|
||||
);
|
||||
|
||||
for (t, team) in event.teams.iter_mut().enumerate() {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
let old_likelihood = self.skills.get(item.agent).unwrap().likelihood;
|
||||
let new_likelihood = (old_likelihood / item.likelihood) * g.likelihoods[t][i];
|
||||
self.skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
|
||||
item.likelihood = g.likelihoods[t][i];
|
||||
for (t, team) in event.teams.iter_mut().enumerate() {
|
||||
for (i, item) in team.items.iter_mut().enumerate() {
|
||||
let old_likelihood = self.skills.get(item.agent).unwrap().likelihood;
|
||||
let new_likelihood =
|
||||
(old_likelihood / item.likelihood) * g.likelihoods[t][i];
|
||||
self.skills.get_mut(item.agent).unwrap().likelihood = new_likelihood;
|
||||
item.likelihood = g.likelihoods[t][i];
|
||||
}
|
||||
}
|
||||
|
||||
event.evidence = g.evidence;
|
||||
}
|
||||
} else {
|
||||
self.sweep_color_groups(agents);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full event sweep using the color-group partition. Colors are processed
|
||||
/// sequentially; within each color the inner loop is parallel under rayon.
|
||||
///
|
||||
/// Events within each color group touch disjoint agent sets (guaranteed by
|
||||
/// the greedy coloring). This lets each rayon thread write directly to its
|
||||
/// events' skill likelihoods without a deferred-apply step, matching the
|
||||
/// sequential path's allocation profile. The unsafe block is sound because:
|
||||
/// 1. `self.events[range]` and `self.skills` are separate fields → disjoint.
|
||||
/// 2. Events in the same color group access disjoint `Index` values in
|
||||
/// `self.skills`, so concurrent writes land on different memory locations.
|
||||
/// 3. Each event only writes to its own items' likelihoods (no sharing).
|
||||
#[cfg(feature = "rayon")]
|
||||
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
use rayon::prelude::*;
|
||||
|
||||
thread_local! {
|
||||
static ARENA: std::cell::RefCell<ScratchArena> =
|
||||
std::cell::RefCell::new(ScratchArena::new());
|
||||
}
|
||||
|
||||
// Minimum color-group size to justify rayon's task-spawn overhead.
|
||||
// Below this threshold, process events sequentially to avoid regression
|
||||
// on small per-slice workloads.
|
||||
const RAYON_THRESHOLD: usize = 64;
|
||||
|
||||
for color_idx in 0..self.color_groups.groups.len() {
|
||||
let group_len = self.color_groups.groups[color_idx].len();
|
||||
if group_len == 0 {
|
||||
continue;
|
||||
}
|
||||
let range = self.color_groups.color_range(color_idx);
|
||||
let p_draw = self.p_draw;
|
||||
|
||||
if group_len >= RAYON_THRESHOLD {
|
||||
// Obtain a raw pointer from the unique `&mut self.skills` reference.
|
||||
// Casting back to `&mut` inside the closure is sound because:
|
||||
// 1. The pointer originates from a `&mut` — no aliasing with shared refs.
|
||||
// 2. Events in the same color group touch disjoint `Index` slots in the
|
||||
// underlying Vec, so concurrent writes from different threads land on
|
||||
// different memory locations — no data race.
|
||||
// 3. `self.events[range]` and `self.skills` are separate struct fields,
|
||||
// so the borrow splits cleanly.
|
||||
let skills_addr: usize = (&mut self.skills as *mut SkillStore) as usize;
|
||||
self.events[range].par_iter_mut().for_each(move |ev| {
|
||||
// SAFETY: see above.
|
||||
let skills: &mut SkillStore = unsafe { &mut *(skills_addr as *mut SkillStore) };
|
||||
ARENA.with(|cell| {
|
||||
let mut arena = cell.borrow_mut();
|
||||
arena.reset();
|
||||
ev.iteration_direct(skills, agents, p_draw, &mut arena);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
for ev in &mut self.events[range] {
|
||||
ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event.evidence = g.evidence;
|
||||
/// Full event sweep using the color-group partition, sequential direct-write path.
|
||||
/// Events within each color group are updated inline — no EventOutput allocation —
|
||||
/// matching the T2 performance profile.
|
||||
#[cfg(not(feature = "rayon"))]
|
||||
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
for color_idx in 0..self.color_groups.groups.len() {
|
||||
if self.color_groups.groups[color_idx].is_empty() {
|
||||
continue;
|
||||
}
|
||||
let range = self.color_groups.color_range(color_idx);
|
||||
|
||||
// Borrow self.events as a mutable slice for this color range.
|
||||
// self.skills and self.arena are separate fields — disjoint borrows are
|
||||
// allowed within a single method body.
|
||||
let p_draw = self.p_draw;
|
||||
for ev in &mut self.events[range] {
|
||||
ev.iteration_direct(&mut self.skills, agents, p_draw, &mut self.arena);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +479,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
self.events
|
||||
.iter()
|
||||
.map(|event| {
|
||||
Game::new(
|
||||
Game::ranked_with_arena(
|
||||
event.within_priors(online, forward, &self.skills, agents),
|
||||
&event.outputs(),
|
||||
&event.weights,
|
||||
@@ -341,7 +505,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
.any(|item| targets.contains(&item.agent))
|
||||
})
|
||||
.map(|(_, event)| {
|
||||
Game::new(
|
||||
Game::ranked_with_arena(
|
||||
event.within_priors(online, forward, &self.skills, agents),
|
||||
&event.outputs(),
|
||||
&event.weights,
|
||||
@@ -656,4 +820,67 @@ mod tests {
|
||||
epsilon = 1e-6
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_slice_color_groups_reorders_events() {
|
||||
// ev0: [a, b]; ev1: [c, d]; ev2: [a, c]
|
||||
// Greedy coloring: ev0→c0, ev1→c0 (disjoint), ev2→c1 (overlaps both).
|
||||
// After recompute_color_groups, physical order is [ev0, ev1, ev2]
|
||||
// and groups == [[0, 1], [2]].
|
||||
let mut index_map = KeyTable::new();
|
||||
|
||||
let a = index_map.get_or_create("a");
|
||||
let b = index_map.get_or_create("b");
|
||||
let c = index_map.get_or_create("c");
|
||||
let d = index_map.get_or_create("d");
|
||||
|
||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
|
||||
for agent in [a, b, c, d] {
|
||||
agents.insert(
|
||||
agent,
|
||||
Competitor {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut ts = TimeSlice::new(0i64, 0.0);
|
||||
|
||||
ts.add_events(
|
||||
vec![
|
||||
vec![vec![a], vec![b]],
|
||||
vec![vec![c], vec![d]],
|
||||
vec![vec![a], vec![c]],
|
||||
],
|
||||
vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]],
|
||||
vec![],
|
||||
&agents,
|
||||
);
|
||||
|
||||
assert_eq!(ts.color_groups.n_colors(), 2);
|
||||
assert_eq!(ts.color_groups.groups[0], vec![0, 1]);
|
||||
assert_eq!(ts.color_groups.groups[1], vec![2]);
|
||||
|
||||
assert_eq!(ts.color_groups.color_range(0), 0..2);
|
||||
assert_eq!(ts.color_groups.color_range(1), 2..3);
|
||||
|
||||
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
|
||||
// checking that the agent sets of self.events[0] and self.events[1] do
|
||||
// not include the agent at self.events[2].
|
||||
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect();
|
||||
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect();
|
||||
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().collect();
|
||||
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
|
||||
assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag)));
|
||||
// ev2 must share an agent with ev0 or ev1 (it needed its own color).
|
||||
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag));
|
||||
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag));
|
||||
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
|
||||
}
|
||||
}
|
||||
|
||||
225
tests/api_shape.rs
Normal file
225
tests/api_shape.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
//! Tests for the new T2 public API surface: typed add_events(iter) and the
|
||||
//! fluent event builder (added in Task 16).
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||
|
||||
#[test]
|
||||
fn add_events_bulk_via_iter() {
|
||||
let mut h = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(2.0)
|
||||
.beta(1.0)
|
||||
.p_draw(0.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
})
|
||||
.build();
|
||||
|
||||
let events: Vec<Event<i64, &'static str>> = vec![
|
||||
Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
},
|
||||
Event {
|
||||
time: 2,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("b")]),
|
||||
Team::with_members([Member::new("c")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
},
|
||||
];
|
||||
|
||||
h.add_events(events).unwrap();
|
||||
let report = h.converge().unwrap();
|
||||
assert!(report.converged);
|
||||
assert!(h.lookup(&"a").is_some());
|
||||
assert!(h.lookup(&"b").is_some());
|
||||
assert!(h.lookup(&"c").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_events_draw() {
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.25)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.build();
|
||||
|
||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("alice")]),
|
||||
Team::with_members([Member::new("bob")]),
|
||||
],
|
||||
outcome: Outcome::draw(2),
|
||||
}];
|
||||
h.add_events(events).unwrap();
|
||||
h.converge().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_events_rejects_mismatched_outcome_ranks() {
|
||||
use trueskill_tt::InferenceError;
|
||||
let mut h: History = History::builder().build();
|
||||
let events: Vec<Event<i64, &'static str>> = vec![Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::ranking([0, 1, 2]), // 3 ranks but 2 teams
|
||||
}];
|
||||
let err = h.add_events(events).unwrap_err();
|
||||
assert!(matches!(err, InferenceError::MismatchedShape { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluent_event_builder_basic() {
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.build();
|
||||
|
||||
h.event(1)
|
||||
.team(["alice", "bob"])
|
||||
.weights([1.0, 0.7])
|
||||
.team(["carol"])
|
||||
.ranking([1, 0])
|
||||
.commit()
|
||||
.unwrap();
|
||||
|
||||
let report = h.converge().unwrap();
|
||||
assert!(report.converged);
|
||||
assert!(h.lookup(&"alice").is_some());
|
||||
assert!(h.lookup(&"bob").is_some());
|
||||
assert!(h.lookup(&"carol").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluent_event_builder_winner_convenience() {
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.build();
|
||||
|
||||
h.event(1)
|
||||
.team(["alice"])
|
||||
.team(["bob"])
|
||||
.winner(0)
|
||||
.commit()
|
||||
.unwrap();
|
||||
h.converge().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fluent_event_builder_draw() {
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.25)
|
||||
.build();
|
||||
|
||||
h.event(1)
|
||||
.team(["alice"])
|
||||
.team(["bob"])
|
||||
.draw()
|
||||
.commit()
|
||||
.unwrap();
|
||||
h.converge().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_skill_and_learning_curve() {
|
||||
use trueskill_tt::History;
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.build();
|
||||
h.record_winner(&"a", &"b", 1).unwrap();
|
||||
h.record_winner(&"a", &"b", 2).unwrap();
|
||||
h.converge().unwrap();
|
||||
|
||||
let a = h.current_skill(&"a").unwrap();
|
||||
assert!(a.mu() > 25.0);
|
||||
let b = h.current_skill(&"b").unwrap();
|
||||
assert!(b.mu() < 25.0);
|
||||
|
||||
let a_curve = h.learning_curve(&"a");
|
||||
assert_eq!(a_curve.len(), 2);
|
||||
assert_eq!(a_curve[0].0, 1);
|
||||
assert_eq!(a_curve[1].0, 2);
|
||||
|
||||
let all = h.learning_curves();
|
||||
assert_eq!(all.len(), 2);
|
||||
assert!(all.contains_key("a"));
|
||||
assert!(all.contains_key("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_evidence_total_vs_subset() {
|
||||
use trueskill_tt::{ConstantDrift, History};
|
||||
let mut h = History::builder()
|
||||
.mu(0.0)
|
||||
.sigma(6.0)
|
||||
.beta(1.0)
|
||||
.p_draw(0.0)
|
||||
.drift(ConstantDrift(0.0))
|
||||
.build();
|
||||
h.record_winner(&"a", &"b", 1).unwrap();
|
||||
h.record_winner(&"b", &"a", 2).unwrap();
|
||||
let total = h.log_evidence();
|
||||
let a_only = h.log_evidence_for(&[&"a"]);
|
||||
assert!(total.is_finite());
|
||||
assert!(a_only.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predict_quality_two_teams() {
|
||||
use trueskill_tt::History;
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.build();
|
||||
h.record_winner(&"a", &"b", 1).unwrap();
|
||||
h.converge().unwrap();
|
||||
|
||||
let q = h.predict_quality(&[&[&"a"], &[&"b"]]);
|
||||
assert!(q > 0.0 && q <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predict_outcome_two_teams_sums_to_one() {
|
||||
use trueskill_tt::History;
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.p_draw(0.0)
|
||||
.build();
|
||||
h.record_winner(&"a", &"b", 1).unwrap();
|
||||
h.converge().unwrap();
|
||||
|
||||
let p = h.predict_outcome(&[&[&"a"], &[&"b"]]);
|
||||
assert_eq!(p.len(), 2);
|
||||
assert!((p[0] + p[1] - 1.0).abs() < 1e-9);
|
||||
assert!(p[0] > p[1]);
|
||||
}
|
||||
100
tests/determinism.rs
Normal file
100
tests/determinism.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
//! Determinism tests: identical posteriors across RAYON_NUM_THREADS
|
||||
//! values. Only compiled with the `rayon` feature.
|
||||
|
||||
#![cfg(feature = "rayon")]
|
||||
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||
|
||||
/// Build a deterministic workload using a simple LCG (no external rand crate).
|
||||
fn build_and_converge(seed: u64) -> Vec<(i64, trueskill_tt::Gaussian)> {
|
||||
let mut h = History::<i64, _, _, String>::builder_with_key()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
})
|
||||
.build();
|
||||
|
||||
// LCG for deterministic pseudo-random ints.
|
||||
let mut rng = seed;
|
||||
let mut next = || {
|
||||
rng = rng
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
rng
|
||||
};
|
||||
|
||||
let mut events: Vec<Event<i64, String>> = Vec::with_capacity(200);
|
||||
for ev_i in 0..200 {
|
||||
let a = (next() % 40) as usize;
|
||||
let mut b = (next() % 40) as usize;
|
||||
while b == a {
|
||||
b = (next() % 40) as usize;
|
||||
}
|
||||
// ~10 events per slice so color groups have material parallelism.
|
||||
events.push(Event {
|
||||
time: (ev_i as i64 / 10) + 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new(format!("p{a}"))]),
|
||||
Team::with_members([Member::new(format!("p{b}"))]),
|
||||
],
|
||||
outcome: Outcome::winner((next() % 2) as u32, 2),
|
||||
});
|
||||
}
|
||||
h.add_events(events).unwrap();
|
||||
h.converge().unwrap();
|
||||
// Sample one competitor's curve for the comparison.
|
||||
h.learning_curve("p0")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posteriors_identical_across_thread_counts() {
|
||||
let sizes = [1usize, 2, 4, 8];
|
||||
let mut results: Vec<Vec<(i64, trueskill_tt::Gaussian)>> = Vec::new();
|
||||
for &n in &sizes {
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(n)
|
||||
.build()
|
||||
.expect("rayon pool build");
|
||||
let curve = pool.install(|| build_and_converge(42));
|
||||
results.push(curve);
|
||||
}
|
||||
|
||||
let reference = &results[0];
|
||||
for (i, curve) in results.iter().enumerate().skip(1) {
|
||||
assert_eq!(
|
||||
curve.len(),
|
||||
reference.len(),
|
||||
"curve length differs at {n} threads",
|
||||
n = sizes[i],
|
||||
);
|
||||
for (j, (&(t_ref, g_ref), &(t, g))) in reference.iter().zip(curve.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
t_ref,
|
||||
t,
|
||||
"time point {j} differs at {n} threads: ref={t_ref} vs got={t}",
|
||||
n = sizes[i],
|
||||
);
|
||||
assert_eq!(
|
||||
g_ref.mu().to_bits(),
|
||||
g.mu().to_bits(),
|
||||
"mu bits differ at {n} threads, time {t}: ref={ref_mu} got={got_mu}",
|
||||
n = sizes[i],
|
||||
ref_mu = g_ref.mu(),
|
||||
got_mu = g.mu(),
|
||||
);
|
||||
assert_eq!(
|
||||
g_ref.sigma().to_bits(),
|
||||
g.sigma().to_bits(),
|
||||
"sigma bits differ at {n} threads, time {t}: ref={ref_sigma} got={got_sigma}",
|
||||
n = sizes[i],
|
||||
ref_sigma = g_ref.sigma(),
|
||||
got_sigma = g.sigma(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
tests/equivalence.rs
Normal file
61
tests/equivalence.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! Equivalence tests: every historical golden from the pre-T2 tests is
|
||||
//! reproduced here at the integration level via the new public API.
|
||||
//!
|
||||
//! The in-crate tests in `src/history.rs::tests` and
|
||||
//! `src/time_slice.rs::tests` are the primary regression net for numerical
|
||||
//! behavior. This file provides Game-level goldens that stand alone and are
|
||||
//! more naturally expressed as integration tests.
|
||||
|
||||
use approx::assert_ulps_eq;
|
||||
use trueskill_tt::{ConstantDrift, Game, GameOptions, Gaussian, Outcome, Rating};
|
||||
|
||||
type R = Rating<i64, ConstantDrift>;
|
||||
|
||||
fn ts_rating(mu: f64, sigma: f64, beta: f64, gamma: f64) -> R {
|
||||
R::new(Gaussian::from_ms(mu, sigma), beta, ConstantDrift(gamma))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_1v1_golden_matches_historical() {
|
||||
let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
||||
let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
||||
let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap();
|
||||
// Historical golden from pre-T2 test_1vs1 (team 0 wins):
|
||||
assert_ulps_eq!(
|
||||
a_post,
|
||||
Gaussian::from_ms(29.205220, 7.194481),
|
||||
epsilon = 1e-6
|
||||
);
|
||||
assert_ulps_eq!(
|
||||
b_post,
|
||||
Gaussian::from_ms(20.794779, 7.194481),
|
||||
epsilon = 1e-6
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_1v1_draw_golden() {
|
||||
let a = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
||||
let b = ts_rating(25.0, 25.0 / 3.0, 25.0 / 6.0, 25.0 / 300.0);
|
||||
let g = Game::<i64, _>::ranked(
|
||||
&[&[a], &[b]],
|
||||
Outcome::draw(2),
|
||||
&GameOptions {
|
||||
p_draw: 0.25,
|
||||
convergence: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let p = g.posteriors();
|
||||
// Historical golden from pre-T2 test_1vs1_draw:
|
||||
assert_ulps_eq!(
|
||||
p[0][0],
|
||||
Gaussian::from_ms(24.999999, 6.469480),
|
||||
epsilon = 1e-6
|
||||
);
|
||||
assert_ulps_eq!(
|
||||
p[1][0],
|
||||
Gaussian::from_ms(24.999999, 6.469480),
|
||||
epsilon = 1e-6
|
||||
);
|
||||
}
|
||||
96
tests/game.rs
Normal file
96
tests/game.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use trueskill_tt::{
|
||||
ConstantDrift, ConvergenceOptions, Game, GameOptions, Gaussian, InferenceError, Outcome, Rating,
|
||||
};
|
||||
|
||||
type R = Rating<i64, ConstantDrift>;
|
||||
|
||||
fn default_rating() -> R {
|
||||
R::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
25.0 / 6.0,
|
||||
ConstantDrift(25.0 / 300.0),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_ranked_1v1_golden() {
|
||||
let a = default_rating();
|
||||
let b = default_rating();
|
||||
let g = Game::<i64, _>::ranked(
|
||||
&[&[a], &[b]],
|
||||
Outcome::winner(0, 2),
|
||||
&GameOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = g.posteriors();
|
||||
assert!(p[0][0].mu() > 25.0);
|
||||
assert!(p[1][0].mu() < 25.0);
|
||||
assert!((p[0][0].sigma() - p[1][0].sigma()).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_one_v_one_shortcut() {
|
||||
let a = default_rating();
|
||||
let b = default_rating();
|
||||
let (a_post, b_post) = Game::<i64, _>::one_v_one(&a, &b, Outcome::winner(0, 2)).unwrap();
|
||||
assert!(a_post.mu() > 25.0);
|
||||
assert!(b_post.mu() < 25.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_ranked_rejects_bad_p_draw() {
|
||||
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
|
||||
let err = Game::<i64, _>::ranked(
|
||||
&[&[a], &[a]],
|
||||
Outcome::winner(0, 2),
|
||||
&GameOptions {
|
||||
p_draw: 1.5,
|
||||
convergence: ConvergenceOptions::default(),
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, InferenceError::InvalidProbability { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_ranked_rejects_mismatched_ranks() {
|
||||
let a = R::new(Gaussian::default(), 1.0, ConstantDrift(0.0));
|
||||
let err = Game::<i64, _>::ranked(
|
||||
&[&[a], &[a]],
|
||||
Outcome::ranking([0, 1, 2]),
|
||||
&GameOptions::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, InferenceError::MismatchedShape { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_free_for_all_three_players() {
|
||||
let a = default_rating();
|
||||
let b = default_rating();
|
||||
let c = default_rating();
|
||||
let g = Game::<i64, _>::free_for_all(
|
||||
&[&a, &b, &c],
|
||||
Outcome::ranking([0, 1, 2]),
|
||||
&GameOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = g.posteriors();
|
||||
assert_eq!(p.len(), 3);
|
||||
assert!(p[0][0].mu() > p[1][0].mu());
|
||||
assert!(p[1][0].mu() > p[2][0].mu());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_log_evidence_is_finite() {
|
||||
let a = default_rating();
|
||||
let b = default_rating();
|
||||
let g = Game::<i64, _>::ranked(
|
||||
&[&[a], &[b]],
|
||||
Outcome::winner(0, 2),
|
||||
&GameOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(g.log_evidence().is_finite());
|
||||
assert!(g.log_evidence() < 0.0);
|
||||
}
|
||||
54
tests/record_winner.rs
Normal file
54
tests/record_winner.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, History};
|
||||
|
||||
#[test]
|
||||
fn record_winner_builds_history() {
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 30,
|
||||
epsilon: 1e-6,
|
||||
})
|
||||
.build();
|
||||
|
||||
h.record_winner(&"alice", &"bob", 1).unwrap();
|
||||
h.converge().unwrap();
|
||||
|
||||
let a_idx = h.lookup(&"alice").unwrap();
|
||||
let b_idx = h.lookup(&"bob").unwrap();
|
||||
|
||||
assert_ne!(a_idx, b_idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intern_is_idempotent() {
|
||||
let mut h: History = History::builder().build();
|
||||
let a1 = h.intern(&"alice");
|
||||
let a2 = h.intern(&"alice");
|
||||
assert_eq!(a1, a2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_returns_none_for_missing() {
|
||||
let h: History = History::builder().build();
|
||||
assert!(h.lookup(&"nobody").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_draw_with_p_draw_set() {
|
||||
let mut h = History::builder()
|
||||
.mu(25.0)
|
||||
.sigma(25.0 / 3.0)
|
||||
.beta(25.0 / 6.0)
|
||||
.drift(ConstantDrift(25.0 / 300.0))
|
||||
.p_draw(0.25)
|
||||
.build();
|
||||
|
||||
h.record_draw(&"alice", &"bob", 1).unwrap();
|
||||
h.converge().unwrap();
|
||||
|
||||
assert!(h.lookup(&"alice").is_some());
|
||||
assert!(h.lookup(&"bob").is_some());
|
||||
}
|
||||
Reference in New Issue
Block a user