filtered_learning_curve's signature mirrors learning_curve, which is cheap per key — so the mirroring trained callers to assume this one is too. It is a full forward pass per call, making the natural loop over competitors O(competitors * events). The doc now says so in complexity terms and points multi-key callers at the plural form. filtered_log_evidence claimed each event is scored "using only what was known before it". That is exact for a slice holding one event, but events sharing a timestamp inform each other through the within-slice sweep, so the honest claim is "before that time". The behaviour is deliberate and matches log_evidence's own convention; only the promise was too strong. This branch exists because a feature's documentation was quietly false. Shipping it with two more overstated doc comments would be a poor joke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T5SYDExxL4vZgvunrcNSMc
2139 lines
69 KiB
Rust
2139 lines
69 KiB
Rust
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, FilteredStep, 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,
|
||
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,
|
||
score_sigma: self.score_sigma,
|
||
convergence: self.convergence,
|
||
observer: self.observer,
|
||
_time: self._time,
|
||
_key: self._key,
|
||
}
|
||
}
|
||
|
||
/// Probability that two evenly-matched sides draw.
|
||
///
|
||
/// Must be in `[0.0, 1.0)`. A zero draw probability asserts that draws
|
||
/// cannot occur, so ingesting a tied outcome then fails with
|
||
/// `InferenceError::TieWithoutDrawProbability`.
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Panics if `p_draw` is outside `[0.0, 1.0)` or is NaN.
|
||
pub fn p_draw(mut self, p_draw: f64) -> Self {
|
||
assert!(
|
||
(0.0..1.0).contains(&p_draw),
|
||
"p_draw must be in [0.0, 1.0) (got {p_draw})"
|
||
);
|
||
self.p_draw = p_draw;
|
||
self
|
||
}
|
||
|
||
/// Default observation noise for scored outcomes.
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Panics if `score_sigma` is not strictly positive.
|
||
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
|
||
}
|
||
|
||
/// Convergence tolerance, iteration cap, and EP damping.
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Panics if `alpha` is outside `(0.0, 1.0]`, or if `epsilon` is negative
|
||
/// or NaN. An `alpha` of zero would leave every EP update unapplied, so
|
||
/// inference would silently return the priors.
|
||
pub fn convergence(mut self, opts: ConvergenceOptions) -> Self {
|
||
assert!(
|
||
opts.alpha > 0.0 && opts.alpha <= 1.0,
|
||
"convergence alpha must be in (0.0, 1.0] (got {})",
|
||
opts.alpha
|
||
);
|
||
assert!(
|
||
opts.epsilon >= 0.0,
|
||
"convergence epsilon must be non-negative (got {})",
|
||
opts.epsilon
|
||
);
|
||
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,
|
||
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,
|
||
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,
|
||
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,
|
||
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,
|
||
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
|
||
}
|
||
|
||
/// Number of distinct time slices in the history.
|
||
#[must_use]
|
||
pub fn time_slices_len(&self) -> usize {
|
||
self.time_slices.len()
|
||
}
|
||
|
||
/// Learning curves for all competitors, keyed by their user-facing key.
|
||
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()
|
||
}
|
||
|
||
/// Filtered learning curves for all competitors, keyed by user-facing key.
|
||
///
|
||
/// Each point is the posterior using only events up to and including that
|
||
/// time — "what we knew then". Contrast `learning_curves`, whose points
|
||
/// are smoothed and so incorporate rounds played later.
|
||
///
|
||
/// Runs a full forward pass per call and caches nothing. This is the
|
||
/// entry point for multi-key work — see `filtered_learning_curve` for
|
||
/// why calling that once per key is far more expensive.
|
||
pub fn filtered_learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
|
||
let mut data: HashMap<K, Vec<(T, Gaussian)>> = HashMap::new();
|
||
|
||
for (time, step) in self.filtered_pass() {
|
||
for (agent, posterior) in step.posteriors {
|
||
if let Some(key) = self.keys.key(agent).cloned() {
|
||
data.entry(key).or_default().push((time, posterior));
|
||
}
|
||
}
|
||
}
|
||
|
||
data
|
||
}
|
||
|
||
/// Filtered learning curve for a single key: (time, posterior) pairs in
|
||
/// time order.
|
||
///
|
||
/// Despite mirroring `learning_curve`'s signature, this is not the cheap
|
||
/// per-key lookup that method is: it runs a full forward pass, O(events),
|
||
/// discarding every posterior but the requested key's. N keys fetched
|
||
/// this way costs O(N * events); use `filtered_learning_curves` for
|
||
/// multi-key work instead — it computes the same pass once.
|
||
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
|
||
where
|
||
K: Borrow<Q>,
|
||
Q: Hash + Eq + ?Sized,
|
||
{
|
||
let Some(idx) = self.keys.get(key) else {
|
||
return Vec::new();
|
||
};
|
||
|
||
self.filtered_pass()
|
||
.into_iter()
|
||
.filter_map(|(time, step)| {
|
||
step.posteriors
|
||
.iter()
|
||
.find(|(agent, _)| *agent == idx)
|
||
.map(|&(_, posterior)| (time, 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(targets, forward, &self.agents))
|
||
.collect();
|
||
per_slice.into_iter().sum()
|
||
}
|
||
#[cfg(not(feature = "rayon"))]
|
||
{
|
||
self.time_slices
|
||
.iter()
|
||
.map(|ts| ts.log_evidence(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)
|
||
}
|
||
|
||
/// Walk the slices in time order carrying forward messages only.
|
||
///
|
||
/// This is the forward half of `iteration` with the backward half never
|
||
/// run. It reads `self` and mutates nothing.
|
||
fn filtered_pass(&self) -> Vec<(T, FilteredStep)> {
|
||
let mut messages: HashMap<Index, Gaussian> = HashMap::new();
|
||
|
||
let mut pass = Vec::with_capacity(self.time_slices.len());
|
||
|
||
for slice in &self.time_slices {
|
||
let step = slice.filtered_step(&messages, &self.agents);
|
||
|
||
for &(agent, posterior) in &step.posteriors {
|
||
messages.insert(agent, posterior);
|
||
}
|
||
|
||
pass.push((slice.time, step));
|
||
}
|
||
|
||
pass
|
||
}
|
||
|
||
/// Total log-evidence under forward-only (filtering) information.
|
||
///
|
||
/// Each event is scored using only what was known before that *time*,
|
||
/// which is the right quantity for prequential scoring and model
|
||
/// comparison. Events sharing a timestamp still inform each other
|
||
/// through the within-slice sweep, so within one slice this is not a
|
||
/// guarantee that event A is scored independently of simultaneous event
|
||
/// B. Contrast `log_evidence`, whose per-event priors carry information
|
||
/// from events that had not happened yet.
|
||
///
|
||
/// Runs a full forward pass per call and caches nothing. The result does
|
||
/// not depend on whether `converge` has been called.
|
||
#[must_use]
|
||
pub fn filtered_log_evidence(&self) -> f64 {
|
||
self.filtered_pass()
|
||
.iter()
|
||
.map(|(_, step)| step.log_evidence)
|
||
.sum()
|
||
}
|
||
|
||
/// Draw-probability quality metric for the given teams (key slices).
|
||
///
|
||
/// Values range roughly [0, 1]; 1 == perfectly matched. Supports any
|
||
/// number of teams.
|
||
///
|
||
/// # Panics
|
||
///
|
||
/// Panics if fewer than two teams are supplied, or if a team resolves to
|
||
/// no known competitors — keys absent from the history, or competitors
|
||
/// with no recorded skill, are dropped, so a team of entirely-unknown
|
||
/// keys becomes empty. Use `lookup` to check keys first.
|
||
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) {
|
||
continue;
|
||
}
|
||
|
||
if let Some(teams) = crate::first_tied_output(event_results) {
|
||
return Err(InferenceError::TieWithoutDrawProbability { teams });
|
||
}
|
||
}
|
||
}
|
||
|
||
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(×, 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);
|
||
}
|
||
|
||
k += 1;
|
||
} 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, &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_forward = h.log_evidence_internal(true, &[]);
|
||
|
||
assert_ulps_eq!(
|
||
trueskill_log_evidence,
|
||
trueskill_log_evidence_forward,
|
||
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:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|