Files
trueskill-tt/src/history.rs
T

2013 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{borrow::Borrow, collections::HashMap, hash::Hash, marker::PhantomData};
use crate::{
BETA, GAMMA, Index, MU, N_INF, P_DRAW, SIGMA,
competitor::{self, Competitor},
convergence::{ConvergenceOptions, ConvergenceReport},
drift::{ConstantDrift, Drift},
error::InferenceError,
gaussian::Gaussian,
key_table::KeyTable,
observer::{NullObserver, Observer},
rating::Rating,
sort_time,
storage::CompetitorStore,
time::Time,
time_slice::{self, EventKind, TimeSlice},
tuple_gt, tuple_max,
};
#[derive(Clone)]
pub struct HistoryBuilder<
T: Time = i64,
D: Drift<T> = ConstantDrift,
O: Observer<T> = NullObserver,
K: Eq + Hash + Clone = &'static str,
> {
mu: f64,
sigma: f64,
beta: f64,
drift: D,
p_draw: f64,
online: bool,
score_sigma: f64,
convergence: ConvergenceOptions,
observer: O,
_time: PhantomData<T>,
_key: PhantomData<K>,
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<T, D, O, K> {
pub fn mu(mut self, mu: f64) -> Self {
self.mu = mu;
self
}
pub fn sigma(mut self, sigma: f64) -> Self {
self.sigma = sigma;
self
}
pub fn beta(mut self, beta: f64) -> Self {
self.beta = beta;
self
}
pub fn drift<D2: Drift<T>>(self, drift: D2) -> HistoryBuilder<T, D2, O, K> {
HistoryBuilder {
drift,
mu: self.mu,
sigma: self.sigma,
beta: self.beta,
p_draw: self.p_draw,
online: self.online,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer: self.observer,
_time: self._time,
_key: self._key,
}
}
pub fn p_draw(mut self, p_draw: f64) -> Self {
self.p_draw = p_draw;
self
}
pub fn online(mut self, online: bool) -> Self {
self.online = online;
self
}
pub fn score_sigma(mut self, score_sigma: f64) -> Self {
assert!(
score_sigma > 0.0,
"score_sigma must be positive (got {score_sigma})"
);
self.score_sigma = score_sigma;
self
}
pub fn convergence(mut self, opts: ConvergenceOptions) -> Self {
self.convergence = opts;
self
}
pub fn observer<O2: Observer<T>>(self, observer: O2) -> HistoryBuilder<T, D, O2, K> {
HistoryBuilder {
mu: self.mu,
sigma: self.sigma,
beta: self.beta,
drift: self.drift,
p_draw: self.p_draw,
online: self.online,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer,
_time: self._time,
_key: self._key,
}
}
pub fn build(self) -> History<T, D, O, K> {
History {
size: 0,
time_slices: Vec::new(),
agents: CompetitorStore::new(),
keys: KeyTable::new(),
mu: self.mu,
sigma: self.sigma,
beta: self.beta,
drift: self.drift,
p_draw: self.p_draw,
online: self.online,
score_sigma: self.score_sigma,
convergence: self.convergence,
observer: self.observer,
}
}
}
impl Default for HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str> {
fn default() -> Self {
Self {
mu: MU,
sigma: SIGMA,
beta: BETA,
drift: ConstantDrift(GAMMA),
p_draw: P_DRAW,
online: false,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
observer: NullObserver,
_time: PhantomData,
_key: PhantomData,
}
}
}
pub struct History<
T: Time = i64,
D: Drift<T> = ConstantDrift,
O: Observer<T> = NullObserver,
K: Eq + Hash + Clone = &'static str,
> {
size: usize,
pub(crate) time_slices: Vec<TimeSlice<T>>,
pub(crate) agents: CompetitorStore<T, D>,
keys: KeyTable<K>,
mu: f64,
sigma: f64,
beta: f64,
drift: D,
p_draw: f64,
online: bool,
score_sigma: f64,
convergence: ConvergenceOptions,
observer: O,
}
impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
fn default() -> Self {
HistoryBuilder::default().build()
}
}
impl History<i64, ConstantDrift, NullObserver, &'static str> {
pub fn builder() -> HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str> {
HistoryBuilder::default()
}
}
impl<K: Eq + Hash + Clone> History<i64, ConstantDrift, NullObserver, K> {
/// Like `builder()` but uses a custom key type `K` instead of the default `&'static str`.
pub fn builder_with_key() -> HistoryBuilder<i64, ConstantDrift, NullObserver, K> {
HistoryBuilder {
mu: MU,
sigma: SIGMA,
beta: BETA,
drift: ConstantDrift(GAMMA),
p_draw: P_DRAW,
online: false,
score_sigma: 1.0,
convergence: ConvergenceOptions::default(),
observer: NullObserver,
_time: PhantomData,
_key: PhantomData,
}
}
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
pub fn intern<Q>(&mut self, key: &Q) -> Index
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K> + ?Sized,
{
self.keys.get_or_create(key)
}
pub fn lookup<Q>(&self, key: &Q) -> Option<Index>
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K> + ?Sized,
{
self.keys.get(key)
}
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
fn iteration(&mut self) -> (f64, f64) {
let mut step = (0.0, 0.0);
if self.time_slices.is_empty() {
return step;
}
competitor::clean(self.agents.values_mut(), false);
for j in (0..self.time_slices.len() - 1).rev() {
for agent in self.time_slices[j + 1].skills.keys() {
self.agents.get_mut(agent).unwrap().message =
self.time_slices[j + 1].backward_prior_out(&agent, &self.agents);
}
let old = self.time_slices[j].posteriors();
self.time_slices[j].new_backward_info(&self.agents);
let new = self.time_slices[j].posteriors();
step = old
.iter()
.fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a])));
}
competitor::clean(self.agents.values_mut(), false);
for j in 1..self.time_slices.len() {
for agent in self.time_slices[j - 1].skills.keys() {
self.agents.get_mut(agent).unwrap().message =
self.time_slices[j - 1].forward_prior_out(&agent);
}
let old = self.time_slices[j].posteriors();
self.time_slices[j].new_forward_info(&self.agents);
let new = self.time_slices[j].posteriors();
step = old
.iter()
.fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a])));
}
if self.time_slices.len() == 1 {
let old = self.time_slices[0].posteriors();
self.time_slices[0].iteration(0, &self.agents);
let new = self.time_slices[0].posteriors();
step = old
.iter()
.fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a])));
}
step
}
/// Learning curves for all competitors, keyed by their user-facing key.
///
/// Note: `key(idx)` is O(n) per lookup; this method is therefore O(n²)
/// in the number of competitors. Acceptable for T2; T3 may optimize.
pub fn learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
#[cfg(feature = "rayon")]
{
use rayon::prelude::*;
let per_slice: Vec<Vec<(Index, T, Gaussian)>> = self
.time_slices
.par_iter()
.map(|ts| {
ts.skills
.iter()
.map(|(idx, sk)| (idx, ts.time, sk.posterior()))
.collect()
})
.collect();
let mut data: HashMap<K, Vec<(T, Gaussian)>> = HashMap::new();
for slice_contrib in per_slice {
for (idx, t, g) in slice_contrib {
if let Some(key) = self.keys.key(idx).cloned() {
data.entry(key).or_default().push((t, g));
}
}
}
data
}
#[cfg(not(feature = "rayon"))]
{
let mut data: HashMap<K, Vec<(T, Gaussian)>> = HashMap::new();
for slice in &self.time_slices {
for (idx, skill) in slice.skills.iter() {
if let Some(key) = self.keys.key(idx).cloned() {
data.entry(key)
.or_default()
.push((slice.time, skill.posterior()));
}
}
}
data
}
}
/// Skill estimate at the latest time slice the competitor appears in.
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
{
let idx = self.keys.get(key)?;
self.time_slices
.iter()
.rev()
.find_map(|ts| ts.skills.get(idx).map(|sk| sk.posterior()))
}
/// Learning curve for a single key: (time, posterior) pairs in time order.
pub fn learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
{
let Some(idx) = self.keys.get(key) else {
return Vec::new();
};
self.time_slices
.iter()
.filter_map(|ts| ts.skills.get(idx).map(|sk| (ts.time, sk.posterior())))
.collect()
}
pub(crate) fn log_evidence_internal(&mut self, forward: bool, targets: &[Index]) -> f64 {
#[cfg(feature = "rayon")]
{
use rayon::prelude::*;
let per_slice: Vec<f64> = self
.time_slices
.par_iter()
.map(|ts| ts.log_evidence(self.online, targets, forward, &self.agents))
.collect();
per_slice.into_iter().sum()
}
#[cfg(not(feature = "rayon"))]
{
self.time_slices
.iter()
.map(|ts| ts.log_evidence(self.online, targets, forward, &self.agents))
.sum()
}
}
/// Total log-evidence across the history.
pub fn log_evidence(&mut self) -> f64 {
self.log_evidence_internal(false, &[])
}
/// Log-evidence restricted to time slices containing at least one of the
/// given keys. Useful for leave-one-out cross-validation.
pub fn log_evidence_for<Q>(&mut self, keys: &[&Q]) -> f64
where
K: std::borrow::Borrow<Q>,
Q: std::hash::Hash + Eq + ?Sized,
{
let targets: Vec<Index> = keys.iter().filter_map(|k| self.keys.get(*k)).collect();
self.log_evidence_internal(false, &targets)
}
/// Draw-probability quality metric for the given teams (key slices).
///
/// Values range roughly [0, 1]; 1 == perfectly matched.
pub fn predict_quality(&self, teams: &[&[&K]]) -> f64 {
let groups: Vec<Vec<Gaussian>> = teams
.iter()
.map(|team| {
team.iter()
.filter_map(|k| self.keys.get(*k))
.filter_map(|idx| {
self.time_slices
.iter()
.rev()
.find_map(|ts| ts.skills.get(idx).map(|s| s.posterior()))
})
.collect()
})
.collect();
let group_refs: Vec<&[Gaussian]> = groups.iter().map(|g| g.as_slice()).collect();
crate::quality(&group_refs, self.beta)
}
/// 2-team win probability: returns `[P(team0 wins), P(team1 wins)]`.
///
/// Panics if `teams.len() != 2`. N-team support lands in T4.
pub fn predict_outcome(&self, teams: &[&[&K]]) -> Vec<f64> {
assert_eq!(teams.len(), 2, "predict_outcome T2: 2 teams only");
let gather = |team: &[&K]| -> Gaussian {
team.iter()
.filter_map(|k| self.keys.get(*k))
.filter_map(|idx| {
self.time_slices
.iter()
.rev()
.find_map(|ts| ts.skills.get(idx).map(|s| s.posterior()))
})
.fold(crate::N00, |acc, g| acc + g.forget(self.beta.powi(2)))
};
let a = gather(teams[0]);
let b = gather(teams[1]);
let diff = a - b;
let p_a = 1.0 - crate::cdf(0.0, diff.mu(), diff.sigma());
vec![p_a, 1.0 - p_a]
}
/// Run the full forward+backward convergence loop and return a summary.
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
use std::time::Instant;
use smallvec::SmallVec;
let opts = self.convergence;
if self.time_slices.is_empty() {
return Ok(ConvergenceReport {
iterations: 0,
final_step: (0.0, 0.0),
log_evidence: 0.0,
converged: true,
per_iteration_time: SmallVec::new(),
slices_skipped: 0,
});
}
let mut step = (f64::INFINITY, f64::INFINITY);
let mut i = 0;
let mut per_iter: SmallVec<[std::time::Duration; 32]> = SmallVec::new();
while tuple_gt(step, opts.epsilon) && i < opts.max_iter {
let t0 = Instant::now();
step = self.iteration();
per_iter.push(t0.elapsed());
i += 1;
self.observer.on_iteration_end(i, step);
// A non-finite step means EP has broken down; further iterations
// cannot recover, and `tuple_gt` would read NaN as converged.
if !crate::step_is_finite(step) {
break;
}
}
if !crate::step_is_finite(step) {
self.observer.on_converged(i, step, false);
return Err(InferenceError::NonFiniteResult {
context: "History::converge",
step,
});
}
let converged = crate::step_converged(step, opts.epsilon);
let log_evidence = self.log_evidence_internal(false, &[]);
self.observer.on_converged(i, step, converged);
Ok(ConvergenceReport {
iterations: i,
final_step: step,
log_evidence,
converged,
per_iteration_time: per_iter,
slices_skipped: 0,
})
}
}
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O, K> {
pub(crate) fn add_events_with_prior(
&mut self,
composition: Vec<Vec<Vec<Index>>>,
results: Vec<Vec<f64>>,
times: Vec<T>,
weights: Vec<Vec<Vec<f64>>>,
kinds: Vec<EventKind>,
mut priors: HashMap<Index, Rating<T, D>>,
) -> Result<(), InferenceError> {
if !results.is_empty() && results.len() != composition.len() {
return Err(InferenceError::MismatchedShape {
kind: "results",
expected: composition.len(),
got: results.len(),
});
}
if times.len() != composition.len() {
return Err(InferenceError::MismatchedShape {
kind: "times",
expected: composition.len(),
got: times.len(),
});
}
if !weights.is_empty() && weights.len() != composition.len() {
return Err(InferenceError::MismatchedShape {
kind: "weights",
expected: composition.len(),
got: weights.len(),
});
}
if kinds.len() != composition.len() {
return Err(InferenceError::MismatchedShape {
kind: "kinds",
expected: composition.len(),
got: kinds.len(),
});
}
// Chokepoint for tie validation: every ingestion route lands here,
// including `record_draw`, which builds its results directly rather
// than going through `Outcome`.
if self.p_draw == 0.0 {
for (event_results, kind) in results.iter().zip(kinds.iter()) {
if matches!(kind, EventKind::Ranked)
&& let Some(tied) = crate::first_tied_output(event_results)
{
return Err(InferenceError::TieWithoutDrawProbability { teams: tied });
}
}
}
competitor::clean(self.agents.values_mut(), true);
let mut this_agent = Vec::with_capacity(1024);
for agent in composition.iter().flatten().flatten() {
if this_agent.contains(agent) {
continue;
}
this_agent.push(*agent);
if !self.agents.contains(*agent) {
self.agents.insert(
*agent,
Competitor {
rating: priors.remove(agent).unwrap_or_else(|| {
Rating::new(
Gaussian::from_ms(self.mu, self.sigma),
self.beta,
self.drift,
)
}),
message: N_INF,
last_time: None,
},
);
}
}
let n = composition.len();
let o = sort_time(&times, false);
let mut i = 0;
let mut k = 0;
while i < n {
let mut j = i + 1;
let t = times[o[i]];
while j < n && times[o[j]] == t {
j += 1;
}
while self.time_slices.len() > k && self.time_slices[k].time < t {
let time_slice = &mut self.time_slices[k];
if k > 0 {
time_slice.new_forward_info(&self.agents);
}
for agent_idx in &this_agent {
if let Some(skill) = time_slice.skills.get_mut(*agent_idx) {
skill.elapsed = time_slice::compute_elapsed(
self.agents[*agent_idx].last_time.as_ref(),
&time_slice.time,
);
let agent = self.agents.get_mut(*agent_idx).unwrap();
agent.last_time = Some(time_slice.time);
agent.message = time_slice.forward_prior_out(agent_idx);
}
}
k += 1;
}
let composition = (i..j)
.map(|e| composition[o[e]].clone())
.collect::<Vec<_>>();
let results = if results.is_empty() {
Vec::new()
} else {
(i..j).map(|e| results[o[e]].clone()).collect::<Vec<_>>()
};
let weights = if weights.is_empty() {
Vec::new()
} else {
(i..j).map(|e| weights[o[e]].clone()).collect::<Vec<_>>()
};
let kinds_chunk: Vec<EventKind> = (i..j).map(|e| kinds[o[e]]).collect();
if self.time_slices.len() > k && self.time_slices[k].time == t {
let time_slice = &mut self.time_slices[k];
time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents);
for agent_idx in time_slice.skills.keys() {
let agent = self.agents.get_mut(agent_idx).unwrap();
agent.last_time = Some(t);
agent.message = time_slice.forward_prior_out(&agent_idx);
}
} else {
let mut time_slice = TimeSlice::new(t, self.p_draw, self.convergence);
time_slice.add_events(composition, results, weights, kinds_chunk, &self.agents);
self.time_slices.insert(k, time_slice);
let time_slice = &self.time_slices[k];
for agent_idx in time_slice.skills.keys() {
let agent = self.agents.get_mut(agent_idx).unwrap();
agent.last_time = Some(t);
agent.message = time_slice.forward_prior_out(&agent_idx);
}
k += 1;
}
i = j;
}
while self.time_slices.len() > k {
let time_slice = &mut self.time_slices[k];
time_slice.new_forward_info(&self.agents);
for agent_idx in &this_agent {
if let Some(skill) = time_slice.skills.get_mut(*agent_idx) {
skill.elapsed = time_slice::compute_elapsed(
self.agents[*agent_idx].last_time.as_ref(),
&time_slice.time,
);
let agent = self.agents.get_mut(*agent_idx).unwrap();
agent.last_time = Some(time_slice.time);
agent.message = time_slice.forward_prior_out(agent_idx);
}
}
k += 1;
}
self.size += n;
Ok(())
}
pub fn record_winner<Q>(&mut self, winner: &Q, loser: &Q, time: T) -> Result<(), InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K> + ?Sized,
{
let w = self.intern(winner);
let l = self.intern(loser);
self.add_events_with_prior(
vec![vec![vec![w], vec![l]]],
vec![vec![1.0, 0.0]],
vec![time],
vec![],
vec![EventKind::Ranked],
HashMap::new(),
)
}
pub fn record_draw<Q>(&mut self, a: &Q, b: &Q, time: T) -> Result<(), InferenceError>
where
K: Borrow<Q>,
Q: Hash + Eq + ToOwned<Owned = K> + ?Sized,
{
let a_idx = self.intern(a);
let b_idx = self.intern(b);
self.add_events_with_prior(
vec![vec![vec![a_idx], vec![b_idx]]],
vec![vec![0.0, 0.0]],
vec![time],
vec![],
vec![EventKind::Ranked],
HashMap::new(),
)
}
/// Start a fluent event builder for a single match at `time`.
pub fn event(&mut self, time: T) -> crate::event_builder::EventBuilder<'_, T, D, O, K> {
crate::event_builder::EventBuilder::new(self, time)
}
/// Bulk-ingest typed events.
pub fn add_events<I>(&mut self, events: I) -> Result<(), InferenceError>
where
I: IntoIterator<Item = crate::event::Event<T, K>>,
{
use crate::event::Event;
let events: Vec<Event<T, K>> = events.into_iter().collect();
if events.is_empty() {
return Ok(());
}
let mut composition: Vec<Vec<Vec<Index>>> = Vec::with_capacity(events.len());
let mut results: Vec<Vec<f64>> = Vec::with_capacity(events.len());
let mut times: Vec<T> = Vec::with_capacity(events.len());
let mut weights: Vec<Vec<Vec<f64>>> = Vec::with_capacity(events.len());
let mut kinds: Vec<EventKind> = Vec::with_capacity(events.len());
let mut priors: HashMap<Index, Rating<T, D>> = HashMap::new();
for ev in events {
if ev.outcome.team_count() != ev.teams.len() {
return Err(InferenceError::MismatchedShape {
kind: "outcome vs teams",
expected: ev.teams.len(),
got: ev.outcome.team_count(),
});
}
let mut event_comp: Vec<Vec<Index>> = Vec::with_capacity(ev.teams.len());
let mut event_weights: Vec<Vec<f64>> = Vec::with_capacity(ev.teams.len());
for team in ev.teams {
let mut team_indices: Vec<Index> = Vec::with_capacity(team.members.len());
let mut team_weights: Vec<f64> = Vec::with_capacity(team.members.len());
for member in team.members {
let idx = self.keys.get_or_create(&member.key);
team_indices.push(idx);
team_weights.push(member.weight);
if let Some(prior) = member.prior {
priors.insert(idx, Rating::new(prior, self.beta, self.drift));
}
}
event_comp.push(team_indices);
event_weights.push(team_weights);
}
composition.push(event_comp);
weights.push(event_weights);
let event_result: Vec<f64> = match &ev.outcome {
crate::Outcome::Ranked(ranks) => {
let max_rank = ranks.iter().copied().max().unwrap_or(0) as f64;
kinds.push(EventKind::Ranked);
ranks.iter().map(|&r| max_rank - r as f64).collect()
}
crate::Outcome::Scored { scores, sigma } => {
let resolved = sigma.unwrap_or(self.score_sigma);
if resolved <= 0.0 || resolved.is_nan() {
return Err(InferenceError::InvalidParameter {
name: "score_sigma",
value: resolved,
});
}
kinds.push(EventKind::Scored {
score_sigma: resolved,
});
scores.to_vec()
}
};
results.push(event_result);
times.push(ev.time);
}
self.add_events_with_prior(composition, results, times, weights, kinds, priors)
}
}
#[cfg(test)]
mod tests {
use approx::assert_ulps_eq;
use smallvec::smallvec;
use super::*;
use crate::{
ConstantDrift, EPSILON, Event, Game, Gaussian, Member, Outcome, P_DRAW, Team,
arena::ScratchArena,
};
fn make_events_1v1(
pairs: &[(&'static str, &'static str)],
outcomes: &[Outcome],
times: &[i64],
) -> Vec<Event<i64, &'static str>> {
pairs
.iter()
.copied()
.zip(outcomes.iter().cloned())
.zip(times.iter().copied())
.map(|(((a, b), outcome), time)| Event {
time,
teams: smallvec![
Team::with_members([Member::new(a)]),
Team::with_members([Member::new(b)]),
],
outcome,
})
.collect()
}
#[test]
fn test_init() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.15 * 25.0 / 3.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h.add_events(events).unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
let p0 = h.time_slices[0].posteriors();
assert_ulps_eq!(
p0[&a],
Gaussian::from_ms(29.205220, 7.194481),
epsilon = 1e-6
);
let observed = h.time_slices[1].skills.get(a).unwrap().forward.sigma();
let gamma: f64 = 0.15 * 25.0 / 3.0;
let expected = (gamma.powi(2)
+ h.time_slices[0]
.skills
.get(a)
.unwrap()
.posterior()
.sigma()
.powi(2))
.sqrt();
assert_ulps_eq!(observed, expected, epsilon = 0.000001);
let observed = h.time_slices[1].skills.get(a).unwrap().posterior();
let w = [vec![1.0], vec![1.0]];
let p = Game::ranked_with_arena(
h.time_slices[1].events[0].within_priors(
false,
false,
&h.time_slices[1].skills,
&h.agents,
),
&[0.0, 1.0],
&w,
P_DRAW,
crate::ConvergenceOptions::default(),
&mut ScratchArena::new(),
)
.posteriors();
let expected = p[0][0];
assert_ulps_eq!(observed, expected, epsilon = 1e-6);
let _ = (b, c);
}
#[test]
fn test_one_batch() {
let mut h1 = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(0.15 * 25.0 / 3.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("b", "c"), ("c", "a")],
&[
Outcome::winner(0, 2),
Outcome::winner(0, 2),
Outcome::winner(0, 2),
],
&[1, 1, 1],
);
h1.add_events(events).unwrap();
let a = h1.keys.get("a").unwrap();
let c = h1.keys.get("c").unwrap();
assert_ulps_eq!(
h1.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(22.904409, 6.010330),
epsilon = 1e-6
);
assert_ulps_eq!(
h1.time_slices[0].skills.get(c).unwrap().posterior(),
Gaussian::from_ms(25.110318, 5.866311),
epsilon = 1e-6
);
h1.converge().unwrap();
assert_ulps_eq!(
h1.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
assert_ulps_eq!(
h1.time_slices[0].skills.get(c).unwrap().posterior(),
Gaussian::from_ms(25.000000, 5.419212),
epsilon = 1e-6
);
let mut h2 = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("b", "c"), ("c", "a")],
&[
Outcome::winner(0, 2),
Outcome::winner(0, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h2.add_events(events).unwrap();
let a = h2.keys.get("a").unwrap();
let c = h2.keys.get("c").unwrap();
assert_ulps_eq!(
h2.time_slices[2].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(22.903522, 6.011017),
epsilon = 1e-6
);
assert_ulps_eq!(
h2.time_slices[2].skills.get(c).unwrap().posterior(),
Gaussian::from_ms(25.110702, 5.866811),
epsilon = 1e-6
);
h2.converge().unwrap();
assert_ulps_eq!(
h2.time_slices[2].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(24.998668, 5.420053),
epsilon = 1e-6
);
assert_ulps_eq!(
h2.time_slices[2].skills.get(c).unwrap().posterior(),
Gaussian::from_ms(25.000532, 5.419827),
epsilon = 1e-6
);
}
#[test]
fn test_learning_curves() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("b", "c"), ("c", "a")],
&[
Outcome::winner(0, 2),
Outcome::winner(0, 2),
Outcome::winner(0, 2),
],
&[5, 6, 7],
);
h.add_events(events).unwrap();
h.converge().unwrap();
let lc_a = h.learning_curve("a");
let lc_c = h.learning_curve("c");
let aj_e = lc_a.len();
let cj_e = lc_c.len();
assert_eq!(lc_a[0].0, 5);
assert_eq!(lc_a[aj_e - 1].0, 7);
assert_ulps_eq!(
lc_a[aj_e - 1].1,
Gaussian::from_ms(24.998668, 5.420053),
epsilon = 1e-6
);
assert_ulps_eq!(
lc_c[cj_e - 1].1,
Gaussian::from_ms(25.000532, 5.419827),
epsilon = 1e-6
);
}
#[test]
fn test_env_ttt() {
let mut h = History::builder()
.mu(25.0)
.sigma(25.0 / 3.0)
.beta(25.0 / 6.0)
.drift(ConstantDrift(25.0 / 300.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h.add_events(events).unwrap();
h.converge().unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2);
assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1);
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(25.000267, 5.419423),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(24.999198, 5.419512),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[2].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(25.001332, 5.420054),
epsilon = 1e-6
);
}
#[test]
fn test_teams() {
let mut h: History<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(6.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.build();
let events: Vec<Event<i64, &'static str>> = vec![
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a"), Member::new("b")]),
Team::with_members([Member::new("c"), Member::new("d")]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 2,
teams: smallvec![
Team::with_members([Member::new("e"), Member::new("f")]),
Team::with_members([Member::new("b"), Member::new("c")]),
],
outcome: Outcome::winner(1, 2),
},
Event {
time: 3,
teams: smallvec![
Team::with_members([Member::new("a"), Member::new("d")]),
Team::with_members([Member::new("e"), Member::new("f")]),
],
outcome: Outcome::winner(0, 2),
},
];
h.add_events(events).unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
let d = h.keys.get("d").unwrap();
let e = h.keys.get("e").unwrap();
let f = h.keys.get("f").unwrap();
let trueskill_log_evidence = h.log_evidence_internal(false, &[]);
let trueskill_log_evidence_online = h.log_evidence_internal(true, &[]);
assert_ulps_eq!(
trueskill_log_evidence,
trueskill_log_evidence_online,
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior().mu(),
-h.time_slices[0].skills.get(c).unwrap().posterior().mu(),
epsilon = 1e-6
);
let evidence_second_event = h.log_evidence_internal(false, &[b]).exp() * 2.0;
assert_ulps_eq!(0.5, evidence_second_event, epsilon = 1e-6);
let evidence_third_event = h.log_evidence_internal(false, &[a]).exp() * 2.0;
assert_ulps_eq!(0.669885, evidence_third_event, epsilon = 1e-6);
h.converge().unwrap();
let loocv_hat = h.log_evidence_internal(false, &[]).exp();
let p_d_m_hat = h.log_evidence_internal(true, &[]).exp();
assert_ulps_eq!(loocv_hat, 0.241027, epsilon = 1e-6);
assert_ulps_eq!(p_d_m_hat, 0.172432, epsilon = 1e-6);
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
h.time_slices[0].skills.get(b).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(c).unwrap().posterior(),
h.time_slices[0].skills.get(d).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[1].skills.get(e).unwrap().posterior(),
h.time_slices[1].skills.get(f).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(4.084902, 5.106919),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(c).unwrap().posterior(),
Gaussian::from_ms(-0.533029, 5.106919),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[2].skills.get(e).unwrap().posterior(),
Gaussian::from_ms(-3.551872, 5.154569),
epsilon = 1e-6
);
}
#[test]
fn test_add_events() {
let mut h: History<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h.add_events(events).unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
h.converge().unwrap();
assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2);
assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1);
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[2].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
let events2 = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[4, 5, 6],
);
h.add_events(events2).unwrap();
assert_eq!(h.time_slices.len(), 6);
assert_eq!(
h.time_slices
.iter()
.map(|b| b.get_composition())
.collect::<Vec<_>>(),
vec![
vec![vec![vec![a], vec![b]]],
vec![vec![vec![a], vec![c]]],
vec![vec![vec![b], vec![c]]],
vec![vec![vec![a], vec![b]]],
vec![vec![vec![a], vec![c]]],
vec![vec![vec![b], vec![c]]]
]
);
h.converge().unwrap();
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[3].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[3].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[5].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
}
#[test]
fn test_only_add_events() {
let mut h: History<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h.add_events(events).unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
h.converge().unwrap();
assert_eq!(h.time_slices[2].skills.get(b).unwrap().elapsed, 2);
assert_eq!(h.time_slices[2].skills.get(c).unwrap().elapsed, 1);
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[2].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 1.300610),
epsilon = 1e-6
);
let events2 = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[4, 5, 6],
);
h.add_events(events2).unwrap();
assert_eq!(h.time_slices.len(), 6);
assert_eq!(
h.time_slices
.iter()
.map(|b| b.get_composition())
.collect::<Vec<_>>(),
vec![
vec![vec![vec![a], vec![b]]],
vec![vec![vec![a], vec![c]]],
vec![vec![vec![b], vec![c]]],
vec![vec![vec![a], vec![b]]],
vec![vec![vec![a], vec![c]]],
vec![vec![vec![b], vec![c]]]
]
);
h.converge().unwrap();
assert_ulps_eq!(
h.time_slices[0].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[3].skills.get(a).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[3].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[5].skills.get(b).unwrap().posterior(),
Gaussian::from_ms(0.000000, 0.931236),
epsilon = 1e-6
);
}
#[test]
fn test_log_evidence() {
use crate::ConvergenceOptions;
let mut h: History<i64, _, _, &'static str> = History::builder().build();
// empty results in the old API = team 0 wins; reproduce with Outcome::winner(0,2)
let events = make_events_1v1(
&[("a", "b"), ("b", "a")],
&[Outcome::winner(0, 2), Outcome::winner(0, 2)],
&[1, 2],
);
h.add_events(events).unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let p_d_m_2 = h.log_evidence_internal(false, &[]).exp() * 2.0;
assert_ulps_eq!(p_d_m_2, 0.17650911, epsilon = 1e-6);
assert_ulps_eq!(
p_d_m_2,
h.log_evidence_internal(true, &[]).exp() * 2.0,
epsilon = 1e-6
);
assert_ulps_eq!(
p_d_m_2,
h.log_evidence_internal(true, &[a]).exp() * 2.0,
epsilon = 1e-6
);
assert_ulps_eq!(
p_d_m_2,
h.log_evidence_internal(false, &[a]).exp() * 2.0,
epsilon = 1e-6
);
// run exactly 11 iterations (old test used convergence(11, ...))
h.convergence = ConvergenceOptions {
max_iter: 11,
epsilon: EPSILON,
alpha: 1.0,
};
h.converge().unwrap();
let loocv_approx_2 = h.log_evidence_internal(false, &[]).exp().sqrt();
assert_ulps_eq!(loocv_approx_2, 0.001976774, epsilon = 0.000001);
let p_d_m_approx_2 = h.log_evidence_internal(true, &[]).exp() * 2.0;
assert!(loocv_approx_2 - p_d_m_approx_2 < 1e-4);
assert_ulps_eq!(
loocv_approx_2,
h.log_evidence_internal(true, &[b]).exp() * 2.0,
epsilon = 1e-4
);
let mut h2: History<i64, _, _, &'static str> = History::builder().build();
let events = make_events_1v1(
&[("a", "b"), ("b", "a")],
&[Outcome::winner(0, 2), Outcome::winner(0, 2)],
&[1, 2],
);
h2.add_events(events).unwrap();
assert_ulps_eq!(
((0.5f64 * 0.1765).ln() / 2.0).exp(),
(h2.log_evidence_internal(false, &[]) / 2.0).exp(),
epsilon = 1e-4
);
}
#[test]
fn test_add_events_with_time() {
let mut h: History<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[0, 10, 20],
);
h.add_events(events).unwrap();
h.converge().unwrap();
let a = h.keys.get("a").unwrap();
let b = h.keys.get("b").unwrap();
let c = h.keys.get("c").unwrap();
let events2 = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[15, 10, 0],
);
h.add_events(events2).unwrap();
assert_eq!(h.time_slices.len(), 4);
assert_eq!(
h.time_slices
.iter()
.map(|ts| ts.events.len())
.collect::<Vec<_>>(),
vec![2, 2, 1, 1]
);
assert_eq!(
h.time_slices
.iter()
.map(|b| b.get_composition())
.collect::<Vec<_>>(),
vec![
vec![vec![vec![a], vec![b]], vec![vec![b], vec![c]]],
vec![vec![vec![a], vec![c]], vec![vec![a], vec![c]]],
vec![vec![vec![a], vec![b]]],
vec![vec![vec![b], vec![c]]]
]
);
assert_eq!(
h.time_slices
.iter()
.map(|b| b.get_results())
.collect::<Vec<_>>(),
vec![
vec![vec![1.0, 0.0], vec![1.0, 0.0]],
vec![vec![0.0, 1.0], vec![0.0, 1.0]],
vec![vec![1.0, 0.0]],
vec![vec![1.0, 0.0]]
]
);
let end = h.time_slices.len() - 1;
assert_eq!(h.time_slices[0].skills.get(c).unwrap().elapsed, 0);
assert_eq!(h.time_slices[end].skills.get(c).unwrap().elapsed, 10);
assert_eq!(h.time_slices[0].skills.get(a).unwrap().elapsed, 0);
assert_eq!(h.time_slices[2].skills.get(a).unwrap().elapsed, 5);
assert_eq!(h.time_slices[0].skills.get(b).unwrap().elapsed, 0);
assert_eq!(h.time_slices[end].skills.get(b).unwrap().elapsed, 5);
h.converge().unwrap();
assert_ulps_eq!(
h.time_slices[0].skills.get(b).unwrap().posterior(),
h.time_slices[end].skills.get(b).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(c).unwrap().posterior(),
h.time_slices[end].skills.get(c).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h.time_slices[0].skills.get(c).unwrap().posterior(),
h.time_slices[0].skills.get(b).unwrap().posterior(),
epsilon = 1e-6
);
// second scenario: team-0 wins (empty results in old API), different composition order
let mut h2: History<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.build();
let events = make_events_1v1(
&[("a", "b"), ("c", "a"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(0, 2),
Outcome::winner(0, 2),
],
&[0, 10, 20],
);
h2.add_events(events).unwrap();
h2.converge().unwrap();
let a = h2.keys.get("a").unwrap();
let b = h2.keys.get("b").unwrap();
let c = h2.keys.get("c").unwrap();
let events2 = make_events_1v1(
&[("a", "b"), ("c", "a"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(0, 2),
Outcome::winner(0, 2),
],
&[15, 10, 0],
);
h2.add_events(events2).unwrap();
assert_eq!(h2.time_slices.len(), 4);
assert_eq!(
h2.time_slices
.iter()
.map(|ts| ts.events.len())
.collect::<Vec<_>>(),
vec![2, 2, 1, 1]
);
assert_eq!(
h2.time_slices
.iter()
.map(|b| b.get_composition())
.collect::<Vec<_>>(),
vec![
vec![vec![vec![a], vec![b]], vec![vec![b], vec![c]]],
vec![vec![vec![c], vec![a]], vec![vec![c], vec![a]]],
vec![vec![vec![a], vec![b]]],
vec![vec![vec![b], vec![c]]]
]
);
assert_eq!(
h2.time_slices
.iter()
.map(|b| b.get_results())
.collect::<Vec<_>>(),
vec![
vec![vec![1.0, 0.0], vec![1.0, 0.0]],
vec![vec![1.0, 0.0], vec![1.0, 0.0]],
vec![vec![1.0, 0.0]],
vec![vec![1.0, 0.0]]
]
);
let end = h2.time_slices.len() - 1;
assert_eq!(h2.time_slices[0].skills.get(c).unwrap().elapsed, 0);
assert_eq!(h2.time_slices[end].skills.get(c).unwrap().elapsed, 10);
assert_eq!(h2.time_slices[0].skills.get(a).unwrap().elapsed, 0);
assert_eq!(h2.time_slices[2].skills.get(a).unwrap().elapsed, 5);
assert_eq!(h2.time_slices[0].skills.get(b).unwrap().elapsed, 0);
assert_eq!(h2.time_slices[end].skills.get(b).unwrap().elapsed, 5);
h2.converge().unwrap();
assert_ulps_eq!(
h2.time_slices[0].skills.get(b).unwrap().posterior(),
h2.time_slices[end].skills.get(b).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h2.time_slices[0].skills.get(c).unwrap().posterior(),
h2.time_slices[end].skills.get(c).unwrap().posterior(),
epsilon = 1e-6
);
assert_ulps_eq!(
h2.time_slices[0].skills.get(c).unwrap().posterior(),
h2.time_slices[0].skills.get(b).unwrap().posterior(),
epsilon = 1e-6
);
}
#[test]
fn test_1vs1_weighted() {
let mut h: History<i64, _, _, &'static str> = History::builder()
.mu(2.0)
.sigma(6.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.build();
// empty results in old API = team 0 wins: a wins event 1, b wins event 2
let events: Vec<Event<i64, &'static str>> = vec![
Event {
time: 1,
teams: smallvec![
Team::with_members([Member::new("a").with_weight(5.0)]),
Team::with_members([Member::new("b").with_weight(4.0)]),
],
outcome: Outcome::winner(0, 2),
},
Event {
time: 2,
teams: smallvec![
Team::with_members([Member::new("b").with_weight(5.0)]),
Team::with_members([Member::new("a").with_weight(4.0)]),
],
outcome: Outcome::winner(0, 2),
},
];
h.add_events(events).unwrap();
let lc_a = h.learning_curve("a");
let lc_b = h.learning_curve("b");
assert_ulps_eq!(
lc_a[0].1,
Gaussian::from_ms(5.537659, 4.758722),
epsilon = 1e-6
);
assert_ulps_eq!(
lc_b[0].1,
Gaussian::from_ms(-0.830127, 5.239568),
epsilon = 1e-6
);
assert_ulps_eq!(
lc_a[1].1,
Gaussian::from_ms(1.792277, 4.099566),
epsilon = 1e-6
);
assert_ulps_eq!(
lc_b[1].1,
Gaussian::from_ms(4.845533, 3.747616),
epsilon = 1e-6
);
h.converge().unwrap();
let lc_a = h.learning_curve("a");
let lc_b = h.learning_curve("b");
assert_ulps_eq!(lc_a[0].1, lc_a[0].1, epsilon = 1e-6);
assert_ulps_eq!(lc_b[0].1, lc_a[0].1, epsilon = 1e-6);
assert_ulps_eq!(lc_a[1].1, lc_a[0].1, epsilon = 1e-6);
assert_ulps_eq!(lc_b[1].1, lc_a[0].1, epsilon = 1e-6);
}
#[test]
fn test_converge_returns_report() {
use crate::ConvergenceOptions;
let mut h: History<i64, _, _, &'static str> = History::builder()
.mu(0.0)
.sigma(2.0)
.beta(1.0)
.drift(ConstantDrift(0.0))
.convergence(ConvergenceOptions {
max_iter: 30,
epsilon: 1e-6,
alpha: 1.0,
})
.build();
let events = make_events_1v1(
&[("a", "b"), ("a", "c"), ("b", "c")],
&[
Outcome::winner(0, 2),
Outcome::winner(1, 2),
Outcome::winner(0, 2),
],
&[1, 2, 3],
);
h.add_events(events).unwrap();
let report = h.converge().unwrap();
assert!(report.converged);
assert!(report.iterations > 0);
assert!(report.iterations < 30);
assert!(report.final_step.0 <= 1e-6);
}
#[test]
#[should_panic(expected = "score_sigma must be positive")]
fn history_builder_rejects_zero_score_sigma() {
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:?}");
}
}
}
}