Compare commits
5
Commits
5f5a37090a
...
56e8220c86
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56e8220c86 | ||
|
|
fdd1539cab | ||
|
|
85c4d0d87d | ||
|
|
a0c2f78aed | ||
|
|
4472d98b56 |
+45
-39
@@ -1,49 +1,55 @@
|
||||
//! One slice's event sweep.
|
||||
//!
|
||||
//! Written against the public API rather than against `TimeSlice` directly.
|
||||
//! It used to reach for `TimeSlice`, `KeyTable`, `CompetitorStore`,
|
||||
//! `Competitor` and `EventKind`, and was the *only* thing outside `src/`
|
||||
//! that did — so a benchmark was dictating five public types that no test,
|
||||
//! example or consumer could otherwise obtain.
|
||||
//!
|
||||
//! A single-slice history's `converge` calls exactly the same per-slice sweep,
|
||||
//! so capping at one iteration measures the same code path.
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use trueskill_tt::{
|
||||
BETA, Competitor, ConvergenceOptions, EventKind, GAMMA, KeyTable, MU, P_DRAW, Rating, SIGMA,
|
||||
TimeSlice, drift::ConstantDrift, gaussian::Gaussian, storage::CompetitorStore,
|
||||
};
|
||||
use smallvec::smallvec;
|
||||
use trueskill_tt::{ConstantDrift, ConvergenceOptions, Event, History, Member, Outcome, Team};
|
||||
|
||||
fn criterion_benchmark(criterion: &mut Criterion) {
|
||||
let mut index_map = KeyTable::new();
|
||||
let build = || {
|
||||
let mut h = History::builder()
|
||||
.convergence(ConvergenceOptions {
|
||||
max_iter: 1,
|
||||
epsilon: 0.0,
|
||||
alpha: 1.0,
|
||||
})
|
||||
.drift(ConstantDrift::new(0.0))
|
||||
.build();
|
||||
|
||||
let a = index_map.get_or_create("a");
|
||||
let b = index_map.get_or_create("b");
|
||||
let c = index_map.get_or_create("c");
|
||||
// 100 events, all at one time, so the history has a single slice.
|
||||
let events: Vec<Event<i64, &'static str>> = (0..100)
|
||||
.map(|_| Event {
|
||||
time: 1,
|
||||
teams: smallvec![
|
||||
Team::with_members([Member::new("a")]),
|
||||
Team::with_members([Member::new("b")]),
|
||||
],
|
||||
outcome: Outcome::winner(0, 2),
|
||||
})
|
||||
.collect();
|
||||
h.add_events(events).expect("fixture ingests");
|
||||
h
|
||||
};
|
||||
|
||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
|
||||
for agent in [a, b, c] {
|
||||
agents.insert(
|
||||
agent,
|
||||
Competitor {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(MU, SIGMA),
|
||||
BETA,
|
||||
ConstantDrift::new(GAMMA),
|
||||
),
|
||||
..Default::default()
|
||||
criterion.bench_function("slice_sweep_100_events", |b| {
|
||||
b.iter_batched(
|
||||
build,
|
||||
|mut h| {
|
||||
// `converge_partial`, not `converge`: one iteration is
|
||||
// deliberately short of convergence and `converge` reports that
|
||||
// as an error.
|
||||
let _ = h.converge_partial();
|
||||
},
|
||||
criterion::BatchSize::SmallInput,
|
||||
);
|
||||
}
|
||||
|
||||
let mut composition = Vec::new();
|
||||
let mut results = Vec::new();
|
||||
let mut weights = Vec::new();
|
||||
|
||||
for _ in 0..100 {
|
||||
composition.push(vec![vec![a], vec![b]]);
|
||||
results.push(vec![1.0, 0.0]);
|
||||
weights.push(vec![vec![1.0], vec![1.0]]);
|
||||
}
|
||||
|
||||
let kinds = vec![EventKind::Ranked; composition.len()];
|
||||
|
||||
let mut time_slice = TimeSlice::new(1, P_DRAW, ConvergenceOptions::default());
|
||||
time_slice.add_events(composition, Some(results), Some(weights), kinds, &agents);
|
||||
|
||||
criterion.bench_function("Batch::iteration", |b| {
|
||||
b.iter(|| time_slice.iteration(0, &agents))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-5
@@ -68,11 +68,7 @@ impl Default for ConvergenceOptions {
|
||||
/// [`InferenceError::NotConverged`](crate::InferenceError::NotConverged) there.
|
||||
/// From [`History::converge_partial`](crate::History::converge_partial) it may
|
||||
/// not be, and `converged` is what says so.
|
||||
#[derive(Clone, Debug)]
|
||||
#[must_use = "from `converge_partial` this may describe a fit that stopped at \
|
||||
`max_iter`, which is wrong by a little rather than loudly \
|
||||
broken — check `converged`, or bind it to `_` to say you have \
|
||||
decided not to"]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ConvergenceReport {
|
||||
pub iterations: usize,
|
||||
pub final_step: (f64, f64),
|
||||
|
||||
+16
-1
@@ -43,32 +43,37 @@ pub enum UnknownKeys {
|
||||
#[non_exhaustive]
|
||||
pub enum InferenceError {
|
||||
/// Expected and actual lengths of some array-shaped input differ.
|
||||
#[non_exhaustive]
|
||||
MismatchedShape {
|
||||
kind: &'static str,
|
||||
expected: usize,
|
||||
got: usize,
|
||||
},
|
||||
/// An `Outcome` of the wrong variant was supplied for the requested inference.
|
||||
#[non_exhaustive]
|
||||
WrongOutcomeKind {
|
||||
context: &'static str,
|
||||
expected: &'static str,
|
||||
got: &'static str,
|
||||
},
|
||||
/// A probability value is outside `[0, 1]`.
|
||||
#[non_exhaustive]
|
||||
InvalidProbability { value: f64 },
|
||||
/// A scalar parameter is outside its valid range.
|
||||
#[non_exhaustive]
|
||||
InvalidParameter { name: &'static str, value: f64 },
|
||||
/// An event contains tied teams, but the draw probability is zero.
|
||||
///
|
||||
/// A zero draw probability asserts that draws cannot occur, so a tied
|
||||
/// result has no representable likelihood. Configure a positive `p_draw`
|
||||
/// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties.
|
||||
#[non_exhaustive]
|
||||
TieWithoutDrawProbability { teams: (usize, usize) },
|
||||
/// The convergence sweep hit `max_iter` with the step still above
|
||||
/// `epsilon`.
|
||||
///
|
||||
/// A fit that stops short is wrong by a little, which is the worst
|
||||
/// available failure: every rating is finite, the ordering looks sensible,
|
||||
/// available failure: every posterior is finite, the ordering looks sensible,
|
||||
/// and nothing in the numbers says they were still moving. Reported rather
|
||||
/// than returned as a flag on an `Ok`, because a flag has to be checked
|
||||
/// and `let _ = h.converge()` is the natural way not to.
|
||||
@@ -77,6 +82,7 @@ pub enum InferenceError {
|
||||
/// oscillating rather than converging, in which case `alpha < 1.0` damps
|
||||
/// the within-game EP loop. [`History::converge_partial`](crate::History::converge_partial)
|
||||
/// returns the short fit instead when that is genuinely what is wanted.
|
||||
#[non_exhaustive]
|
||||
NotConverged {
|
||||
iterations: usize,
|
||||
final_step: (f64, f64),
|
||||
@@ -86,6 +92,7 @@ pub enum InferenceError {
|
||||
///
|
||||
/// Indicates numerical breakdown; the resulting skills are meaningless
|
||||
/// and must not be treated as a converged estimate.
|
||||
#[non_exhaustive]
|
||||
NonFiniteResult {
|
||||
context: &'static str,
|
||||
step: (f64, f64),
|
||||
@@ -99,6 +106,7 @@ pub enum InferenceError {
|
||||
/// "last one wins" would make the result depend on iteration order.
|
||||
/// Declaring the same value repeatedly is fine and is the expected shape
|
||||
/// when a competitor's configuration is a property of the domain.
|
||||
#[non_exhaustive]
|
||||
ConflictingCompetitorConfig {
|
||||
competitor: usize,
|
||||
field: &'static str,
|
||||
@@ -113,6 +121,7 @@ pub enum InferenceError {
|
||||
/// `UnknownKey { team: 0, member: 0 }` learns nothing about *which* of its
|
||||
/// keys the history has not seen, and the natural handling — fall back to a
|
||||
/// neutral value — turns the whole thing into a plausible constant.
|
||||
#[non_exhaustive]
|
||||
UnknownKey {
|
||||
team: usize,
|
||||
member: usize,
|
||||
@@ -128,8 +137,10 @@ pub enum InferenceError {
|
||||
///
|
||||
/// To change an existing competitor's configuration, supply it on an event
|
||||
/// through `Member`; that refits the whole history.
|
||||
#[non_exhaustive]
|
||||
AlreadyRegistered { key: String },
|
||||
/// A prediction was given a team with no members.
|
||||
#[non_exhaustive]
|
||||
EmptyTeam { team: usize },
|
||||
/// The prediction grid cannot resolve the narrowest feature in the matchup.
|
||||
///
|
||||
@@ -147,6 +158,7 @@ pub enum InferenceError {
|
||||
/// `predict_win_probabilities` answers the same matchup through adaptive
|
||||
/// quadrature and is accurate here; use it when only the per-team win
|
||||
/// probabilities are needed.
|
||||
#[non_exhaustive]
|
||||
GridTooCoarse {
|
||||
/// Nodes required to resolve the narrowest feature.
|
||||
needed: usize,
|
||||
@@ -154,8 +166,10 @@ pub enum InferenceError {
|
||||
max: usize,
|
||||
},
|
||||
/// A joint posterior was requested where one cannot be formed exactly.
|
||||
#[non_exhaustive]
|
||||
JointUnavailable { reason: &'static str },
|
||||
/// Fewer than two teams were supplied to a prediction.
|
||||
#[non_exhaustive]
|
||||
NotEnoughTeams { got: usize },
|
||||
/// The full outcome distribution was requested for too many teams.
|
||||
///
|
||||
@@ -165,6 +179,7 @@ pub enum InferenceError {
|
||||
/// enumerate on a caller's behalf; ask for individual rankings with
|
||||
/// `predict_ranking`, or for `predict_win_probabilities`, both of which
|
||||
/// stay cheap at any team count.
|
||||
#[non_exhaustive]
|
||||
TooManyTeams { got: usize, max: usize },
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@ use smallvec::SmallVec;
|
||||
use crate::{gaussian::Gaussian, outcome::Outcome, time::Time};
|
||||
|
||||
/// A single match at time `time` involving some number of teams.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Event<T: Time, K> {
|
||||
pub time: T,
|
||||
pub teams: SmallVec<[Team<K>; 4]>,
|
||||
@@ -19,7 +19,7 @@ pub struct Event<T: Time, K> {
|
||||
}
|
||||
|
||||
/// A team: list of members competing together.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Team<K> {
|
||||
pub members: SmallVec<[Member<K>; 4]>,
|
||||
}
|
||||
@@ -61,7 +61,7 @@ impl<K> Default for Team<K> {
|
||||
/// for one competitor within a single batch is
|
||||
/// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no
|
||||
/// order, so there would be no well-defined winner.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Member<K> {
|
||||
pub key: K,
|
||||
pub weight: f64,
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::{
|
||||
time::Time,
|
||||
};
|
||||
|
||||
#[must_use = "an event is only recorded by `.commit()`; a dropped builder \
|
||||
silently ingests nothing"]
|
||||
pub struct EventBuilder<'h, T, D, O, K>
|
||||
where
|
||||
T: Time,
|
||||
|
||||
@@ -44,7 +44,6 @@ impl VarStore {
|
||||
id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, id: VarId) -> Gaussian {
|
||||
self.marginals[id.0 as usize]
|
||||
}
|
||||
|
||||
+17
-14
@@ -92,6 +92,7 @@ impl Default for GameOptions {
|
||||
/// can be returned freely from public constructors. The inference inputs
|
||||
/// themselves are not retained — nothing reads them back.
|
||||
#[derive(Debug)]
|
||||
#[must_use]
|
||||
pub struct OwnedGame<T: Time, D: Drift<T>> {
|
||||
teams: Vec<Vec<Rating<T, D>>>,
|
||||
pub(crate) likelihoods: Vec<Vec<Gaussian>>,
|
||||
@@ -283,7 +284,9 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
self.teams[t]
|
||||
.iter()
|
||||
.zip(self.weights[t].iter())
|
||||
.fold(N00, |p, (player, &w)| p + (player.performance() * w))
|
||||
.fold(N00, |p, (competitor, &w)| {
|
||||
p + (competitor.performance() * w)
|
||||
})
|
||||
}));
|
||||
|
||||
let n_diffs = n_teams.saturating_sub(1);
|
||||
@@ -360,18 +363,18 @@ impl<'a, T: Time, D: Drift<T>> Game<'a, T, D> {
|
||||
.iter()
|
||||
.zip(self.weights.iter())
|
||||
.enumerate()
|
||||
.map(|(orig_i, (players, weights))| {
|
||||
.map(|(orig_i, (competitors, weights))| {
|
||||
let si = arena.inv_buf[orig_i];
|
||||
let m = arena.lhood_win[si] * arena.lhood_lose[si];
|
||||
// Already folded into `team_prior` at the top of the chain,
|
||||
// indexed by sorted position.
|
||||
let performance = arena.team_prior[si];
|
||||
players
|
||||
competitors
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(player, &w)| {
|
||||
((m - performance.exclude(player.performance() * w)) * (1.0 / w))
|
||||
.forget(player.beta.powi(2))
|
||||
.map(|(competitor, &w)| {
|
||||
((m - performance.exclude(competitor.performance() * w)) * (1.0 / w))
|
||||
.forget(competitor.beta.powi(2))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
@@ -576,7 +579,7 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Convenience wrapper over [`Game::ranked`] for two single-player teams.
|
||||
/// Convenience wrapper over [`Game::ranked`] for two single-competitor teams.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -596,14 +599,14 @@ impl<T: Time, D: Drift<T>> Game<'_, T, D> {
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// Wraps each player in a one-member team and delegates to
|
||||
/// Wraps each competitor in a one-member team and delegates to
|
||||
/// [`Game::ranked`], so it returns the same errors.
|
||||
pub fn free_for_all(
|
||||
players: &[&Rating<T, D>],
|
||||
competitors: &[&Rating<T, D>],
|
||||
outcome: crate::Outcome,
|
||||
options: &GameOptions,
|
||||
) -> Result<OwnedGame<T, D>, crate::InferenceError> {
|
||||
let teams: Vec<Vec<Rating<T, D>>> = players.iter().map(|p| vec![**p]).collect();
|
||||
let teams: Vec<Vec<Rating<T, D>>> = competitors.iter().map(|p| vec![**p]).collect();
|
||||
let team_refs: Vec<&[Rating<T, D>]> = teams.iter().map(|t| t.as_slice()).collect();
|
||||
Self::ranked(&team_refs, outcome, options)
|
||||
}
|
||||
@@ -1427,8 +1430,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn run_chain_honours_max_iter_in_convergence_options() {
|
||||
let players: Vec<R> = (0..4).map(|_| R::default()).collect();
|
||||
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
|
||||
let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
|
||||
let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
|
||||
let result = vec![3.0, 2.0, 1.0, 0.0];
|
||||
let weights = vec![vec![1.0]; 4];
|
||||
|
||||
@@ -1475,8 +1478,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn run_chain_with_damping_converges_to_same_posterior() {
|
||||
let players: Vec<R> = (0..4).map(|_| R::default()).collect();
|
||||
let teams: Vec<Vec<_>> = players.iter().map(|p| vec![*p]).collect();
|
||||
let competitors: Vec<R> = (0..4).map(|_| R::default()).collect();
|
||||
let teams: Vec<Vec<_>> = competitors.iter().map(|p| vec![*p]).collect();
|
||||
let result = vec![3.0, 2.0, 1.0, 0.0];
|
||||
let weights = vec![vec![1.0]; 4];
|
||||
|
||||
|
||||
+1
-2
@@ -11,6 +11,7 @@ use crate::{MU, N_INF, SIGMA};
|
||||
/// the stored fields with no `sqrt` or reciprocal in the hot path. `mu()` and
|
||||
/// `sigma()` are accessors computed on demand.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
#[must_use]
|
||||
pub struct Gaussian {
|
||||
pi: f64,
|
||||
tau: f64,
|
||||
@@ -44,7 +45,6 @@ impl Gaussian {
|
||||
/// small truncated sigma and inference must not panic. It is worth knowing
|
||||
/// that such a `Gaussian` is not equal to itself, so two identical
|
||||
/// declarations of one can be reported as conflicting.
|
||||
#[must_use]
|
||||
pub const fn from_ms(mu: f64, sigma: f64) -> Self {
|
||||
// NaN is admitted on purpose. A broken fit legitimately produces a NaN
|
||||
// sigma — `sqrt` of a negative truncated variance — and the design is
|
||||
@@ -243,7 +243,6 @@ impl Gaussian {
|
||||
/// Used by within-game inference to stabilise oscillating fixed-point
|
||||
/// loops on hard graphs. `alpha = 1.0` returns `new` exactly;
|
||||
/// `alpha < 1.0` shrinks each per-step update.
|
||||
#[must_use]
|
||||
pub fn damp_natural(self, new: Gaussian, alpha: f64) -> Gaussian {
|
||||
Gaussian::from_natural(
|
||||
alpha * new.pi() + (1.0 - alpha) * self.pi(),
|
||||
|
||||
+215
-109
@@ -24,7 +24,8 @@ use crate::{
|
||||
tuple_gt, tuple_max,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
#[must_use = "a builder does nothing until `.build()`"]
|
||||
pub struct HistoryBuilder<
|
||||
T: Time = i64,
|
||||
D: Drift<T> = ConstantDrift,
|
||||
@@ -197,7 +198,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
||||
/// assert!(h.current_skill(&"alice").unwrap().mu() > 0.0);
|
||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn time_type<T2>(self) -> HistoryBuilder<T2, D, O, K>
|
||||
where
|
||||
T2: Time,
|
||||
@@ -232,7 +232,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
||||
/// h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)?;
|
||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn key_type<K2: Eq + Hash + Clone>(self) -> HistoryBuilder<T, D, O, K2> {
|
||||
HistoryBuilder {
|
||||
mu: self.mu,
|
||||
@@ -269,7 +268,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
||||
History {
|
||||
size: 0,
|
||||
time_slices: Vec::new(),
|
||||
agents: CompetitorStore::new(),
|
||||
competitors: CompetitorStore::new(),
|
||||
keys: KeyTable::new(),
|
||||
mu: self.mu,
|
||||
sigma: self.sigma,
|
||||
@@ -392,7 +391,7 @@ pub struct History<
|
||||
> {
|
||||
size: usize,
|
||||
pub(crate) time_slices: Vec<TimeSlice<T>>,
|
||||
pub(crate) agents: CompetitorStore<T, D>,
|
||||
pub(crate) competitors: CompetitorStore<T, D>,
|
||||
keys: KeyTable<K>,
|
||||
mu: f64,
|
||||
sigma: f64,
|
||||
@@ -418,7 +417,6 @@ impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
|
||||
}
|
||||
|
||||
impl History<i64, ConstantDrift, NullObserver, &'static str> {
|
||||
#[must_use]
|
||||
pub fn builder() -> HistoryBuilder<i64, ConstantDrift, NullObserver, &'static str> {
|
||||
HistoryBuilder::default()
|
||||
}
|
||||
@@ -440,7 +438,6 @@ impl<T: Time, K: Eq + Hash + Clone> HistoryBuilder<T, ConstantDrift, NullObserve
|
||||
/// h.converge()?;
|
||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
@@ -455,6 +452,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
self.keys.get_or_create(key)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn lookup<Q>(&self, key: &Q) -> Option<Index>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
@@ -472,17 +470,18 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
return step;
|
||||
}
|
||||
|
||||
competitor::clean(self.agents.values_mut(), false);
|
||||
competitor::clean(self.competitors.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 =
|
||||
Some(self.time_slices[j + 1].backward_prior_out(&agent, &self.agents));
|
||||
for competitor in self.time_slices[j + 1].skills.keys() {
|
||||
self.competitors.get_mut(competitor).unwrap().message = Some(
|
||||
self.time_slices[j + 1].backward_prior_out(&competitor, &self.competitors),
|
||||
);
|
||||
}
|
||||
|
||||
let old = self.time_slices[j].posteriors();
|
||||
|
||||
self.time_slices[j].new_backward_info(&self.agents);
|
||||
self.time_slices[j].new_backward_info(&self.competitors);
|
||||
self.observer.on_slice_processed(
|
||||
&self.time_slices[j].time,
|
||||
j,
|
||||
@@ -496,17 +495,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
.fold(step, |step, (a, old)| tuple_max(step, old.delta(new[a])));
|
||||
}
|
||||
|
||||
competitor::clean(self.agents.values_mut(), false);
|
||||
competitor::clean(self.competitors.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 =
|
||||
Some(self.time_slices[j - 1].forward_prior_out(&agent));
|
||||
for competitor in self.time_slices[j - 1].skills.keys() {
|
||||
self.competitors.get_mut(competitor).unwrap().message =
|
||||
Some(self.time_slices[j - 1].forward_prior_out(&competitor));
|
||||
}
|
||||
|
||||
let old = self.time_slices[j].posteriors();
|
||||
|
||||
self.time_slices[j].new_forward_info(&self.agents);
|
||||
self.time_slices[j].new_forward_info(&self.competitors);
|
||||
self.observer.on_slice_processed(
|
||||
&self.time_slices[j].time,
|
||||
j,
|
||||
@@ -523,7 +522,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
if self.time_slices.len() == 1 {
|
||||
let old = self.time_slices[0].posteriors();
|
||||
|
||||
self.time_slices[0].iteration(0, &self.agents);
|
||||
self.time_slices[0].iteration(0, &self.competitors);
|
||||
self.observer.on_slice_processed(
|
||||
&self.time_slices[0].time,
|
||||
0,
|
||||
@@ -546,7 +545,42 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
self.time_slices.len()
|
||||
}
|
||||
|
||||
/// Every competitor the history knows, in the order they were first seen.
|
||||
///
|
||||
/// Includes competitors created by [`History::register`] that have not yet
|
||||
/// appeared in an event.
|
||||
///
|
||||
/// Insertion order, not hash order: a `HashMap` walk differs between
|
||||
/// processes, which would make anything built from this — a standings
|
||||
/// table, a printed report — unreproducible.
|
||||
///
|
||||
/// ```
|
||||
/// # use trueskill_tt::History;
|
||||
/// let mut h = History::builder().build();
|
||||
/// h.record_winner(&"alice", &"bob", 1)?;
|
||||
/// let names: Vec<_> = h.competitors().copied().collect();
|
||||
/// assert_eq!(names, ["alice", "bob"]);
|
||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn competitors(&self) -> impl ExactSizeIterator<Item = &K> {
|
||||
self.keys.keys()
|
||||
}
|
||||
|
||||
/// How many competitors the history knows.
|
||||
#[must_use]
|
||||
pub fn competitor_count(&self) -> usize {
|
||||
self.keys.len()
|
||||
}
|
||||
|
||||
/// How many events have been ingested.
|
||||
#[must_use]
|
||||
pub fn event_count(&self) -> usize {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// Learning curves for all competitors, keyed by their user-facing key.
|
||||
#[must_use]
|
||||
pub fn learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
|
||||
#[cfg(feature = "rayon")]
|
||||
{
|
||||
@@ -643,7 +677,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
|
||||
let key = format!("{:?}", member.key);
|
||||
let idx = self.keys.get_or_create(&member.key);
|
||||
if self.agents.contains(idx) {
|
||||
if self.competitors.contains(idx) {
|
||||
return Err(InferenceError::AlreadyRegistered { key });
|
||||
}
|
||||
|
||||
@@ -666,7 +700,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
drift_scale: member.drift_scale,
|
||||
},
|
||||
);
|
||||
self.agents.insert(
|
||||
self.competitors.insert(
|
||||
idx,
|
||||
Competitor {
|
||||
rating,
|
||||
@@ -692,9 +726,12 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
Q: std::hash::Hash + Eq + ?Sized,
|
||||
{
|
||||
let idx = self.keys.get(key)?;
|
||||
self.agents.contains(idx).then(|| self.agents[idx].rating)
|
||||
self.competitors
|
||||
.contains(idx)
|
||||
.then(|| self.competitors[idx].rating)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
|
||||
where
|
||||
K: std::borrow::Borrow<Q>,
|
||||
@@ -708,6 +745,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
|
||||
/// Learning curve for a single key: (time, posterior) pairs in time order.
|
||||
#[must_use]
|
||||
pub fn learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
|
||||
where
|
||||
K: std::borrow::Borrow<Q>,
|
||||
@@ -731,12 +769,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// 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.
|
||||
#[must_use]
|
||||
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() {
|
||||
for (competitor, posterior) in step.posteriors {
|
||||
if let Some(key) = self.keys.key(competitor).cloned() {
|
||||
data.entry(key).or_default().push((time, posterior));
|
||||
}
|
||||
}
|
||||
@@ -753,6 +792,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub fn filtered_learning_curve<Q>(&self, key: &Q) -> Vec<(T, Gaussian)>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
@@ -767,7 +807,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
.filter_map(|(time, step)| {
|
||||
step.posteriors
|
||||
.iter()
|
||||
.find(|(agent, _)| *agent == idx)
|
||||
.find(|(competitor, _)| *competitor == idx)
|
||||
.map(|&(_, posterior)| (time, posterior))
|
||||
})
|
||||
.collect()
|
||||
@@ -786,7 +826,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
// Bound before the closure so it captures the store rather than all of
|
||||
// `&self`: capturing `&History` would drag `KeyTable<K>` in and demand
|
||||
// `K: Sync` from every caller, which the key type need not satisfy.
|
||||
let agents = &self.agents;
|
||||
let competitors = &self.competitors;
|
||||
|
||||
#[cfg(feature = "rayon")]
|
||||
{
|
||||
@@ -794,7 +834,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let per_slice: Vec<f64> = self
|
||||
.time_slices
|
||||
.par_iter()
|
||||
.map(|ts| ts.log_evidence(targets, forward, agents))
|
||||
.map(|ts| ts.log_evidence(targets, forward, competitors))
|
||||
.collect();
|
||||
per_slice.into_iter().sum()
|
||||
}
|
||||
@@ -802,18 +842,20 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
{
|
||||
self.time_slices
|
||||
.iter()
|
||||
.map(|ts| ts.log_evidence(targets, forward, agents))
|
||||
.map(|ts| ts.log_evidence(targets, forward, competitors))
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Total log-evidence across the history.
|
||||
#[must_use]
|
||||
pub fn log_evidence(&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.
|
||||
#[must_use]
|
||||
pub fn log_evidence_for<Q>(&self, keys: &[&Q]) -> f64
|
||||
where
|
||||
K: std::borrow::Borrow<Q>,
|
||||
@@ -833,10 +875,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let mut pass = Vec::with_capacity(self.time_slices.len());
|
||||
|
||||
for slice in &self.time_slices {
|
||||
let step = slice.filtered_step(&messages, &self.agents);
|
||||
let step = slice.filtered_step(&messages, &self.competitors);
|
||||
|
||||
for &(agent, posterior) in &step.posteriors {
|
||||
messages.insert(agent, posterior);
|
||||
for &(competitor, posterior) in &step.posteriors {
|
||||
messages.insert(competitor, posterior);
|
||||
}
|
||||
|
||||
pass.push((slice.time, step));
|
||||
@@ -924,7 +966,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
Some(skill) => skill,
|
||||
None => match self.unknown_keys {
|
||||
crate::UnknownKeys::Prior => Gaussian::from_ms(self.mu, self.sigma),
|
||||
_ => {
|
||||
crate::UnknownKeys::Reject => {
|
||||
return Err(InferenceError::UnknownKey {
|
||||
team: team_idx,
|
||||
member: member_idx,
|
||||
@@ -1081,13 +1123,13 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let mut n = 0usize;
|
||||
|
||||
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
|
||||
for (agent, elapsed) in slice.appearances() {
|
||||
let rating = &self.agents[agent].rating;
|
||||
let row = match previous.get(&agent) {
|
||||
for (competitor, elapsed) in slice.appearances() {
|
||||
let rating = &self.competitors[competitor].rating;
|
||||
let row = match previous.get(&competitor) {
|
||||
None => {
|
||||
let row = n;
|
||||
n += 1;
|
||||
first_rows.push((row, agent));
|
||||
first_rows.push((row, competitor));
|
||||
row
|
||||
}
|
||||
Some(&prev) => {
|
||||
@@ -1104,16 +1146,17 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
}
|
||||
};
|
||||
previous.insert(agent, row);
|
||||
latest.insert(agent, (row, slice_idx));
|
||||
at_slice.insert((agent, slice_idx), row);
|
||||
previous.insert(competitor, row);
|
||||
latest.insert(competitor, (row, slice_idx));
|
||||
at_slice.insert((competitor, slice_idx), row);
|
||||
}
|
||||
}
|
||||
|
||||
let mut lambda = vec![0.0; n * n];
|
||||
|
||||
for (row, agent) in first_rows {
|
||||
lambda[row * n + row] += 1.0 / self.agents[agent].rating.prior.sigma().powi(2);
|
||||
for (row, competitor) in first_rows {
|
||||
lambda[row * n + row] +=
|
||||
1.0 / self.competitors[competitor].rating.prior.sigma().powi(2);
|
||||
}
|
||||
for (a, b, drift) in drift_links {
|
||||
lambda[a * n + a] += 1.0 / drift;
|
||||
@@ -1122,7 +1165,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
lambda[b * n + a] -= 1.0 / drift;
|
||||
}
|
||||
for (slice_idx, slice) in self.time_slices.iter().enumerate() {
|
||||
for (contrast, noise) in slice.scored_contrasts(&self.agents) {
|
||||
for (contrast, noise) in slice.scored_contrasts(&self.competitors) {
|
||||
for (ia, ca) in &contrast {
|
||||
let ra = at_slice[&(*ia, slice_idx)];
|
||||
for (ib, cb) in &contrast {
|
||||
@@ -1181,7 +1224,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
mean += coefficient * self.mu;
|
||||
*unseen.entry(format!("{key:?}")).or_insert(0.0) += coefficient;
|
||||
}
|
||||
_ => {
|
||||
crate::UnknownKeys::Reject => {
|
||||
return Err(InferenceError::UnknownKey {
|
||||
team: 0,
|
||||
member,
|
||||
@@ -1443,7 +1486,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
///
|
||||
/// `MismatchedShape` unless exactly two teams are supplied, `EmptyTeam`,
|
||||
/// `UnknownKey` under [`UnknownKeys::Reject`](crate::UnknownKeys::Reject),
|
||||
/// and `JointUnavailable` if the latest slice holds ranked events.
|
||||
/// and `JointUnavailable` if the history is empty or holds ranked events in
|
||||
/// *any* slice — not merely the latest one.
|
||||
pub fn predict_margin(&self, teams: &[&[&K]]) -> Result<Gaussian, InferenceError>
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
@@ -1471,7 +1515,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let beta = self
|
||||
.keys
|
||||
.get(*key)
|
||||
.map_or(self.beta, |index| self.agents[index].rating.beta);
|
||||
.map_or(self.beta, |index| self.competitors[index].rating.beta);
|
||||
performance_noise += beta * beta;
|
||||
}
|
||||
}
|
||||
@@ -1499,8 +1543,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// elsewhere. See [`expected_information_gain`](crate::expected_information_gain)
|
||||
/// for the scale, the analytic `ln k` ceiling, and the cost.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// # Preconditions
|
||||
///
|
||||
/// Every key must already be known to the history — that is, must have
|
||||
@@ -1511,8 +1553,12 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// whole-team miss into a plausible constant, which is invisible to any
|
||||
/// test that does not assert on variation.
|
||||
///
|
||||
/// As [`History::member_skills`], plus `TooManyTeams` and anything
|
||||
/// inference returns for a hypothetical outcome.
|
||||
/// # Errors
|
||||
///
|
||||
/// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey` and `TooManyTeams` for the
|
||||
/// shape of the request, `GridTooCoarse` when the performance sigmas are
|
||||
/// too far apart to integrate on one grid, and anything inference returns
|
||||
/// for a hypothetical outcome.
|
||||
pub fn expected_information_gain(&self, teams: &[&[&K]]) -> Result<f64, InferenceError>
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
@@ -1613,6 +1659,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// # Errors
|
||||
///
|
||||
/// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey`, or `TooManyTeams`.
|
||||
/// `GridTooCoarse` when the performance sigmas are too far apart to
|
||||
/// integrate on one grid.
|
||||
pub fn predict_outcome(&self, teams: &[&[&K]]) -> Result<Prediction, InferenceError>
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
@@ -1641,8 +1689,6 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// space, so it stays cheap at any team count — use it when you know which
|
||||
/// orderings you care about.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// # Preconditions
|
||||
///
|
||||
/// Every key must already be known to the history — that is, must have
|
||||
@@ -1653,8 +1699,11 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// whole-team miss into a plausible constant, which is invisible to any
|
||||
/// test that does not assert on variation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `NotEnoughTeams`, `EmptyTeam`, `UnknownKey`, or `MismatchedShape` if
|
||||
/// `ranks` does not have one entry per team.
|
||||
/// `ranks` does not have one entry per team. `GridTooCoarse` when the
|
||||
/// performance sigmas are too far apart to integrate on one grid.
|
||||
pub fn predict_ranking(&self, teams: &[&[&K]], ranks: &[u32]) -> Result<f64, InferenceError>
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
@@ -1700,6 +1749,10 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// `NonFiniteResult` if a sweep produces a NaN or infinite step. EP has
|
||||
/// broken down at that point and further iterations cannot recover, so the
|
||||
/// loop stops rather than reporting a NaN step as convergence.
|
||||
///
|
||||
/// `InvalidParameter` if a competitor's drift model yields a negative or
|
||||
/// non-finite variance — which also covers a custom [`Drift`]
|
||||
/// implementation, the one case no constructor can check.
|
||||
pub fn converge(&mut self) -> Result<ConvergenceReport, InferenceError> {
|
||||
let report = self.converge_partial()?;
|
||||
|
||||
@@ -1726,6 +1779,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// # Errors
|
||||
///
|
||||
/// `NonFiniteResult` if a sweep produces a NaN or infinite step.
|
||||
#[must_use = "this fit may have stopped at `max_iter` — check `converged`, \
|
||||
or bind it to `_` to say you have decided not to"]
|
||||
pub fn converge_partial(&mut self) -> Result<ConvergenceReport, InferenceError> {
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -1744,8 +1799,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
// defect already rejected for `sigma` and `beta`. A non-finite gamma
|
||||
// poisons every posterior derived from it.
|
||||
for slice in &self.time_slices {
|
||||
for (agent, elapsed) in slice.appearances() {
|
||||
let drift = self.agents[agent]
|
||||
for (competitor, elapsed) in slice.appearances() {
|
||||
let drift = self.competitors[competitor]
|
||||
.rating
|
||||
.drift_variance_for_elapsed(elapsed);
|
||||
if !drift.is_finite() || drift < 0.0 {
|
||||
@@ -1955,14 +2010,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let mut conflict_scan: Vec<Index> = priors.keys().copied().collect();
|
||||
conflict_scan.sort_unstable();
|
||||
|
||||
for agent in &conflict_scan {
|
||||
let batch = priors[agent];
|
||||
let held = self.declared.get(agent).copied().unwrap_or_default();
|
||||
for competitor in &conflict_scan {
|
||||
let batch = priors[competitor];
|
||||
let held = self.declared.get(competitor).copied().unwrap_or_default();
|
||||
|
||||
if let (Some(existing), Some(new)) = (held.prior, batch.prior) {
|
||||
if existing != new {
|
||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||
competitor: agent.get(),
|
||||
competitor: competitor.get(),
|
||||
field: "prior",
|
||||
});
|
||||
}
|
||||
@@ -1970,15 +2025,15 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
if let (Some(existing), Some(new)) = (held.drift_scale, batch.drift_scale) {
|
||||
if existing != new {
|
||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||
competitor: agent.get(),
|
||||
competitor: competitor.get(),
|
||||
field: "drift_scale",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (agent, batch) in &priors {
|
||||
let entry = self.declared.entry(*agent).or_default();
|
||||
for (competitor, batch) in &priors {
|
||||
let entry = self.declared.entry(*competitor).or_default();
|
||||
if batch.prior.is_some() {
|
||||
entry.prior = batch.prior;
|
||||
}
|
||||
@@ -1987,22 +2042,22 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
}
|
||||
|
||||
competitor::clean(self.agents.values_mut(), true);
|
||||
competitor::clean(self.competitors.values_mut(), true);
|
||||
|
||||
let mut this_agent = Vec::with_capacity(1024);
|
||||
|
||||
for agent in composition.iter().flatten().flatten() {
|
||||
if this_agent.contains(agent) {
|
||||
for competitor in composition.iter().flatten().flatten() {
|
||||
if this_agent.contains(competitor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this_agent.push(*agent);
|
||||
this_agent.push(*competitor);
|
||||
|
||||
// From `declared` rather than `priors`: a competitor configured by
|
||||
// `register` before any event has nothing in this batch's map.
|
||||
let config = self.declared.get(agent).copied().unwrap_or_default();
|
||||
let config = self.declared.get(competitor).copied().unwrap_or_default();
|
||||
|
||||
if self.agents.contains(*agent) {
|
||||
if self.competitors.contains(*competitor) {
|
||||
// Seeding a competitor the history already knows. This used to
|
||||
// be dropped on the floor: `remove` was only reached on the
|
||||
// create path, so a prior applied on a competitor's very first
|
||||
@@ -2011,7 +2066,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
continue;
|
||||
}
|
||||
|
||||
let rating = &mut self.agents.get_mut(*agent).unwrap().rating;
|
||||
let rating = &mut self.competitors.get_mut(*competitor).unwrap().rating;
|
||||
if let Some(prior) = config.prior {
|
||||
rating.prior = prior;
|
||||
}
|
||||
@@ -2031,7 +2086,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
// `clean` has just nulled every message, so the earliest
|
||||
// slice's forward is exactly the prior.
|
||||
for slice in &mut self.time_slices {
|
||||
if let Some(skill) = slice.skills.get_mut(*agent) {
|
||||
if let Some(skill) = slice.skills.get_mut(*competitor) {
|
||||
skill.forward = seeded;
|
||||
break;
|
||||
}
|
||||
@@ -2050,8 +2105,8 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
rating.drift_scale = scale;
|
||||
}
|
||||
|
||||
self.agents.insert(
|
||||
*agent,
|
||||
self.competitors.insert(
|
||||
*competitor,
|
||||
Competitor {
|
||||
rating,
|
||||
message: None,
|
||||
@@ -2093,20 +2148,20 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
let time_slice = &mut self.time_slices[k];
|
||||
|
||||
if k > 0 {
|
||||
time_slice.new_forward_info(&self.agents);
|
||||
time_slice.new_forward_info(&self.competitors);
|
||||
}
|
||||
|
||||
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(),
|
||||
self.competitors[*agent_idx].last_time.as_ref(),
|
||||
&time_slice.time,
|
||||
);
|
||||
|
||||
let agent = self.agents.get_mut(*agent_idx).unwrap();
|
||||
let competitor = self.competitors.get_mut(*agent_idx).unwrap();
|
||||
|
||||
agent.last_time = Some(time_slice.time);
|
||||
agent.message = Some(time_slice.forward_prior_out(agent_idx));
|
||||
competitor.last_time = Some(time_slice.time);
|
||||
competitor.message = Some(time_slice.forward_prior_out(agent_idx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2133,29 +2188,41 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
|
||||
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);
|
||||
time_slice.add_events(
|
||||
composition,
|
||||
results,
|
||||
weights,
|
||||
kinds_chunk,
|
||||
&self.competitors,
|
||||
);
|
||||
|
||||
for agent_idx in time_slice.skills.keys() {
|
||||
let agent = self.agents.get_mut(agent_idx).unwrap();
|
||||
let competitor = self.competitors.get_mut(agent_idx).unwrap();
|
||||
|
||||
agent.last_time = Some(t);
|
||||
agent.message = Some(time_slice.forward_prior_out(&agent_idx));
|
||||
competitor.last_time = Some(t);
|
||||
competitor.message = Some(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);
|
||||
time_slice.add_events(
|
||||
composition,
|
||||
results,
|
||||
weights,
|
||||
kinds_chunk,
|
||||
&self.competitors,
|
||||
);
|
||||
|
||||
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();
|
||||
let competitor = self.competitors.get_mut(agent_idx).unwrap();
|
||||
|
||||
agent.last_time = Some(t);
|
||||
agent.message = Some(time_slice.forward_prior_out(&agent_idx));
|
||||
competitor.last_time = Some(t);
|
||||
competitor.message = Some(time_slice.forward_prior_out(&agent_idx));
|
||||
}
|
||||
|
||||
k += 1;
|
||||
@@ -2167,19 +2234,19 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
while self.time_slices.len() > k {
|
||||
let time_slice = &mut self.time_slices[k];
|
||||
|
||||
time_slice.new_forward_info(&self.agents);
|
||||
time_slice.new_forward_info(&self.competitors);
|
||||
|
||||
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(),
|
||||
self.competitors[*agent_idx].last_time.as_ref(),
|
||||
&time_slice.time,
|
||||
);
|
||||
|
||||
let agent = self.agents.get_mut(*agent_idx).unwrap();
|
||||
let competitor = self.competitors.get_mut(*agent_idx).unwrap();
|
||||
|
||||
agent.last_time = Some(time_slice.time);
|
||||
agent.message = Some(time_slice.forward_prior_out(agent_idx));
|
||||
competitor.last_time = Some(time_slice.time);
|
||||
competitor.message = Some(time_slice.forward_prior_out(agent_idx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2248,10 +2315,18 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
/// # Errors
|
||||
///
|
||||
/// - `MismatchedShape` if an event's outcome does not describe the same
|
||||
/// number of teams the event has, or if per-member weights do not match
|
||||
/// the team's membership.
|
||||
/// - `InvalidParameter` if a per-event `score_sigma` override is not
|
||||
/// strictly positive.
|
||||
/// number of teams the event has. (Weights cannot mismatch here — they
|
||||
/// come one-per-`Member`; that check belongs to
|
||||
/// [`EventBuilder::weights`](crate::EventBuilder::weights), which builds
|
||||
/// them from a separate list.)
|
||||
/// - `NotEnoughTeams` for an event with fewer than two teams, and
|
||||
/// `EmptyTeam` for a team with no members.
|
||||
/// - `InvalidParameter` for a per-event `score_sigma` override that is not
|
||||
/// strictly positive, a non-finite score, rank or weight, or a
|
||||
/// `drift_scale` that is negative or non-finite.
|
||||
/// - `ConflictingCompetitorConfig` if one competitor is given two different
|
||||
/// values for `prior` or `drift_scale`, whether within one batch or
|
||||
/// across batches.
|
||||
/// - `TieWithoutDrawProbability` if an event ties two teams while the
|
||||
/// history's `p_draw` is zero. This includes `Outcome::winner(w, n)` for
|
||||
/// `n >= 3`, which ties every loser.
|
||||
@@ -2379,6 +2454,33 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
}
|
||||
|
||||
/// Summarising rather than exhaustive.
|
||||
///
|
||||
/// A `History` owns every competitor's skill at every time slice, so a derived
|
||||
/// `Debug` would print the entire fit — megabytes for a real history, and
|
||||
/// useless in a log. This prints the shape instead. Same reasoning as `Joint`'s,
|
||||
/// which omits its `n^2` factorisation.
|
||||
///
|
||||
/// It exists at all because without it a consumer cannot `#[derive(Debug)]` on
|
||||
/// any struct holding a `History`, which is how both known consumers store it.
|
||||
impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> std::fmt::Debug
|
||||
for History<T, D, O, K>
|
||||
{
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("History")
|
||||
.field("competitors", &self.keys.len())
|
||||
.field("events", &self.size)
|
||||
.field("time_slices", &self.time_slices.len())
|
||||
.field("mu", &self.mu)
|
||||
.field("sigma", &self.sigma)
|
||||
.field("beta", &self.beta)
|
||||
.field("p_draw", &self.p_draw)
|
||||
.field("score_sigma", &self.score_sigma)
|
||||
.field("unknown_keys", &self.unknown_keys)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// A factorised joint posterior, reusable across many queries.
|
||||
///
|
||||
/// Built by [`History::joint`]. Every question the joint answers — the width of
|
||||
@@ -2520,9 +2622,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
|
||||
if slice.time > time {
|
||||
break;
|
||||
}
|
||||
for (agent, _) in slice.appearances() {
|
||||
if let Some(row) = self.at_slice.get(&(agent, slice_idx)) {
|
||||
as_of.insert(agent, (*row, slice_idx));
|
||||
for (competitor, _) in slice.appearances() {
|
||||
if let Some(row) = self.at_slice.get(&(competitor, slice_idx)) {
|
||||
as_of.insert(competitor, (*row, slice_idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2571,7 +2673,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
|
||||
.keys
|
||||
.get(*key)
|
||||
.map_or(self.history.beta, |index| {
|
||||
self.history.agents[index].rating.beta
|
||||
self.history.competitors[index].rating.beta
|
||||
});
|
||||
noise += beta * beta;
|
||||
}
|
||||
@@ -2726,7 +2828,11 @@ mod tests {
|
||||
|
||||
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),
|
||||
h.time_slices[1].events[0].within_priors(
|
||||
false,
|
||||
&h.time_slices[1].skills,
|
||||
&h.competitors,
|
||||
),
|
||||
&[0.0, 1.0],
|
||||
&w,
|
||||
P_DRAW,
|
||||
@@ -3645,7 +3751,7 @@ mod tests {
|
||||
|
||||
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");
|
||||
let full_pts = curves_full.get(key).expect("competitor 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());
|
||||
@@ -3692,7 +3798,7 @@ mod tests {
|
||||
|
||||
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");
|
||||
let d_pts = curves_d.get(key).expect("competitor 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());
|
||||
@@ -3738,10 +3844,10 @@ mod tests {
|
||||
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");
|
||||
let b_pts = curves_b.get(key).expect("competitor 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:?}");
|
||||
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at competitor {key:?}");
|
||||
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at competitor {key:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3780,10 +3886,10 @@ mod tests {
|
||||
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");
|
||||
let b_pts = curves_b.get(key).expect("competitor 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:?}");
|
||||
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at competitor {key:?}");
|
||||
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at competitor {key:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3803,7 +3909,7 @@ mod tests {
|
||||
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");
|
||||
let c_pts = curves_c.get(key).expect("competitor 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());
|
||||
@@ -3845,10 +3951,10 @@ mod tests {
|
||||
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");
|
||||
let b_pts = curves_b.get(key).expect("competitor 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:?}");
|
||||
assert_eq!(a.1.pi(), b.1.pi(), "mismatch at competitor {key:?}");
|
||||
assert_eq!(a.1.tau(), b.1.tau(), "mismatch at competitor {key:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-7
@@ -60,19 +60,20 @@ where
|
||||
self.reverse.get(idx.0)
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> impl Iterator<Item = &K> {
|
||||
self.forward.keys()
|
||||
/// Every key, in the order they were first interned.
|
||||
///
|
||||
/// Iterates the dense reverse table rather than the forward `HashMap`.
|
||||
/// Rust seeds its default hasher per process, so a `HashMap` walk yields a
|
||||
/// different order on every run — which is fine for membership but not for
|
||||
/// anything a caller might sum, sort or print.
|
||||
pub fn keys(&self) -> impl ExactSizeIterator<Item = &K> {
|
||||
self.reverse.iter()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.reverse.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.reverse.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> Default for KeyTable<K>
|
||||
|
||||
+17
-21
@@ -104,13 +104,10 @@ use std::{
|
||||
f64::consts::{FRAC_1_SQRT_2, FRAC_2_SQRT_PI, SQRT_2},
|
||||
};
|
||||
|
||||
mod acquisition;
|
||||
#[cfg(feature = "approx")]
|
||||
mod approx;
|
||||
pub(crate) mod arena;
|
||||
mod time;
|
||||
mod time_slice;
|
||||
pub use time_slice::{EventKind, TimeSlice};
|
||||
mod acquisition;
|
||||
mod color_group;
|
||||
mod competitor;
|
||||
mod convergence;
|
||||
@@ -130,10 +127,11 @@ mod outcome;
|
||||
mod predict;
|
||||
pub(crate) mod quadrature;
|
||||
mod rating;
|
||||
pub mod storage;
|
||||
pub(crate) mod storage;
|
||||
mod time;
|
||||
mod time_slice;
|
||||
|
||||
pub use acquisition::expected_information_gain;
|
||||
pub use competitor::Competitor;
|
||||
pub use convergence::{ConvergenceOptions, ConvergenceReport};
|
||||
pub use drift::{ConstantDrift, Drift};
|
||||
pub use error::{InferenceError, UnknownKeys};
|
||||
@@ -142,7 +140,6 @@ pub use event_builder::EventBuilder;
|
||||
pub use game::{Game, GameOptions, OwnedGame};
|
||||
pub use gaussian::Gaussian;
|
||||
pub use history::{History, HistoryBuilder, Joint};
|
||||
pub use key_table::KeyTable;
|
||||
use matrix::Matrix;
|
||||
pub use observer::{NullObserver, Observer};
|
||||
pub use outcome::Outcome;
|
||||
@@ -226,9 +223,8 @@ const HALF_LINE_WINDOW: f64 = 10.0;
|
||||
const NARROW_WINDOW_RATIO: f64 = 2.0e4;
|
||||
const ASYMPTOTIC_MILLS_ALPHA: f64 = 100.0;
|
||||
|
||||
pub const N01: Gaussian = Gaussian::from_ms(0.0, 1.0);
|
||||
pub const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
|
||||
pub const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
|
||||
pub(crate) const N00: Gaussian = Gaussian::from_ms(0.0, 0.0);
|
||||
pub(crate) const N_INF: Gaussian = Gaussian::from_ms(0.0, f64::INFINITY);
|
||||
|
||||
#[derive(Copy, Clone, Default, PartialEq, PartialOrd, Eq, Ord, Hash, Debug)]
|
||||
pub struct Index(usize);
|
||||
@@ -750,14 +746,14 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
|
||||
x.into_iter().map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Calculates the match quality of the given rating groups. A result is the draw probability in the association
|
||||
/// Calculates the match quality of the given teams. A result is the draw probability in the association
|
||||
///
|
||||
/// Supports any number of groups. Values range roughly `[0, 1]`; 1 means a
|
||||
/// perfectly balanced match.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if fewer than two rating groups are supplied, or if any group is
|
||||
/// Panics if fewer than two teams are supplied, or if any group is
|
||||
/// empty — match quality is a property of a contest between at least two
|
||||
/// non-empty sides.
|
||||
///
|
||||
@@ -768,18 +764,18 @@ pub(crate) fn sort_time<T: Copy + Ord>(xs: &[T], reverse: bool) -> Vec<usize> {
|
||||
/// converted, because the input has no meaningful answer rather than an
|
||||
/// awkward one.
|
||||
#[must_use]
|
||||
pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
||||
pub fn quality(teams: &[&[Gaussian]], beta: f64) -> f64 {
|
||||
assert!(
|
||||
rating_groups.len() >= 2,
|
||||
"quality() requires at least 2 rating groups, got {}",
|
||||
rating_groups.len()
|
||||
teams.len() >= 2,
|
||||
"quality() requires at least 2 teams, got {}",
|
||||
teams.len()
|
||||
);
|
||||
assert!(
|
||||
rating_groups.iter().all(|group| !group.is_empty()),
|
||||
"quality() requires every rating group to be non-empty"
|
||||
teams.iter().all(|group| !group.is_empty()),
|
||||
"quality() requires every team to be non-empty"
|
||||
);
|
||||
|
||||
let flatten_ratings = rating_groups
|
||||
let flatten_ratings = teams
|
||||
.iter()
|
||||
.flat_map(|group| group.iter())
|
||||
.collect::<Vec<_>>();
|
||||
@@ -800,14 +796,14 @@ pub fn quality(rating_groups: &[&[Gaussian]], beta: f64) -> f64 {
|
||||
variance_matrix[(i, i)] = rating.sigma().powi(2);
|
||||
}
|
||||
|
||||
let mut rotated_a_matrix = Matrix::new(rating_groups.len() - 1, length);
|
||||
let mut rotated_a_matrix = Matrix::new(teams.len() - 1, length);
|
||||
|
||||
// Row `row` contrasts group `row` (+weight) against group `row + 1`
|
||||
// (-weight). `t` is the column where the current group's players start;
|
||||
// the negative block begins immediately after it.
|
||||
let mut t = 0;
|
||||
|
||||
for (row, group) in rating_groups.windows(2).enumerate() {
|
||||
for (row, group) in teams.windows(2).enumerate() {
|
||||
let current = group[0];
|
||||
let next = group[1];
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ use smallvec::SmallVec;
|
||||
#[non_exhaustive]
|
||||
pub enum Outcome {
|
||||
Ranked(SmallVec<[u32; 4]>),
|
||||
#[non_exhaustive]
|
||||
Scored {
|
||||
scores: SmallVec<[f64; 4]>,
|
||||
/// Per-event noise override. `None` means inherit
|
||||
|
||||
@@ -456,6 +456,7 @@ pub(crate) fn ranking_probability(
|
||||
/// `Game::ranked` asks "what would we believe if *this* happened", which is
|
||||
/// what an expected-information-gain calculation needs alongside the weight.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[must_use]
|
||||
pub struct Prediction {
|
||||
outcomes: Vec<(Vec<u32>, f64)>,
|
||||
}
|
||||
|
||||
+1
-2
@@ -11,7 +11,7 @@ use crate::{
|
||||
///
|
||||
/// A configuration rather than a person: the per-history temporal state
|
||||
/// (messages, last appearance) lives on `Competitor`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Rating<T: Time = i64, D: Drift<T> = ConstantDrift> {
|
||||
pub(crate) prior: Gaussian,
|
||||
pub(crate) beta: f64,
|
||||
@@ -61,7 +61,6 @@ impl<T: Time, D: Drift<T>> Rating<T, D> {
|
||||
}
|
||||
|
||||
/// The configured prior skill estimate.
|
||||
#[must_use]
|
||||
pub fn prior(&self) -> Gaussian {
|
||||
self.prior
|
||||
}
|
||||
|
||||
@@ -56,16 +56,16 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
||||
self.get(idx).is_some()
|
||||
}
|
||||
|
||||
/// Test-only: no code path in the crate needs a count.
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.n_present
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.n_present == 0
|
||||
}
|
||||
|
||||
/// Test-only: iterating every competitor is an assertion helper, not part
|
||||
/// of inference, which walks slices rather than the store.
|
||||
#[cfg(test)]
|
||||
pub fn iter(&self) -> impl Iterator<Item = (Index, &Competitor<T, D>)> {
|
||||
self.competitors
|
||||
.iter()
|
||||
@@ -73,13 +73,6 @@ impl<T: Time, D: Drift<T>> CompetitorStore<T, D> {
|
||||
.filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a)))
|
||||
}
|
||||
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (Index, &mut Competitor<T, D>)> {
|
||||
self.competitors
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.filter_map(|(i, slot)| slot.as_mut().map(|a| (Index(i), a)))
|
||||
}
|
||||
|
||||
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut Competitor<T, D>> {
|
||||
self.competitors.iter_mut().filter_map(|s| s.as_mut())
|
||||
}
|
||||
|
||||
+98
-89
@@ -50,12 +50,12 @@ pub enum EventKind {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Item {
|
||||
agent: Index,
|
||||
competitor: Index,
|
||||
/// This competitor's slot in the owning slice's `SkillStore`, resolved
|
||||
/// once at ingestion.
|
||||
///
|
||||
/// The convergence loop reaches skills through this rather than through
|
||||
/// `agent`, which is what keeps `HashMap` hashing out of the hot path now
|
||||
/// `competitor`, which is what keeps `HashMap` hashing out of the hot path now
|
||||
/// that the store is compact rather than indexed by the global `Index`.
|
||||
slot: u32,
|
||||
likelihood: Gaussian,
|
||||
@@ -66,9 +66,9 @@ impl Item {
|
||||
&self,
|
||||
forward: bool,
|
||||
skills: &SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
) -> Rating<T, D> {
|
||||
let r = &agents[self.agent].rating;
|
||||
let r = &competitors[self.competitor].rating;
|
||||
let skill = skills.at(self.slot);
|
||||
|
||||
if forward {
|
||||
@@ -98,7 +98,7 @@ impl Event {
|
||||
pub(crate) fn iter_agents(&self) -> impl Iterator<Item = Index> + '_ {
|
||||
self.teams
|
||||
.iter()
|
||||
.flat_map(|t| t.items.iter().map(|it| it.agent))
|
||||
.flat_map(|t| t.items.iter().map(|it| it.competitor))
|
||||
}
|
||||
|
||||
fn outputs(&self) -> Vec<f64> {
|
||||
@@ -112,14 +112,14 @@ impl Event {
|
||||
&self,
|
||||
forward: bool,
|
||||
skills: &SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
) -> Vec<Vec<Rating<T, D>>> {
|
||||
self.teams
|
||||
.iter()
|
||||
.map(|team| {
|
||||
team.items
|
||||
.iter()
|
||||
.map(|item| item.within_prior(forward, skills, agents))
|
||||
.map(|item| item.within_prior(forward, skills, competitors))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -133,12 +133,12 @@ impl Event {
|
||||
fn compute<T: Time, D: Drift<T>>(
|
||||
&self,
|
||||
skills: &SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
p_draw: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
arena: &mut ScratchArena,
|
||||
) -> EventUpdate {
|
||||
let teams = self.within_priors(false, skills, agents);
|
||||
let teams = self.within_priors(false, skills, competitors);
|
||||
let result = self.outputs();
|
||||
let g = match self.kind {
|
||||
EventKind::Ranked => {
|
||||
@@ -179,12 +179,12 @@ impl Event {
|
||||
fn iteration_direct<T: Time, D: Drift<T>>(
|
||||
&mut self,
|
||||
skills: &mut SkillStore,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
p_draw: f64,
|
||||
convergence: crate::ConvergenceOptions,
|
||||
arena: &mut ScratchArena,
|
||||
) {
|
||||
let update = self.compute(skills, agents, p_draw, convergence, arena);
|
||||
let update = self.compute(skills, competitors, p_draw, convergence, arena);
|
||||
self.apply(skills, update);
|
||||
}
|
||||
}
|
||||
@@ -288,7 +288,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
results: Option<Vec<Vec<f64>>>,
|
||||
weights: Option<Vec<Vec<Vec<f64>>>>,
|
||||
kinds: Vec<EventKind>,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
) {
|
||||
let mut unique = Vec::with_capacity(10);
|
||||
|
||||
@@ -303,9 +303,9 @@ impl<T: Time> TimeSlice<T> {
|
||||
});
|
||||
|
||||
for idx in this_agent {
|
||||
let elapsed = compute_elapsed(agents[*idx].last_time.as_ref(), &self.time);
|
||||
let elapsed = compute_elapsed(competitors[*idx].last_time.as_ref(), &self.time);
|
||||
|
||||
let forward = agents[*idx].receive(&self.time);
|
||||
let forward = competitors[*idx].receive(&self.time);
|
||||
|
||||
if let Some(skill) = self.skills.get_mut(*idx) {
|
||||
skill.elapsed = elapsed;
|
||||
@@ -332,12 +332,12 @@ impl<T: Time> TimeSlice<T> {
|
||||
.map(|(t, team)| {
|
||||
let items = team
|
||||
.iter()
|
||||
.map(|&agent| Item {
|
||||
agent,
|
||||
.map(|&competitor| Item {
|
||||
competitor,
|
||||
// Every participant was inserted into `skills`
|
||||
// just above, so the slot always resolves.
|
||||
slot: skills
|
||||
.slot_of(agent)
|
||||
.slot_of(competitor)
|
||||
.expect("participant must be present in the slice store"),
|
||||
likelihood: N_INF,
|
||||
})
|
||||
@@ -376,7 +376,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
|
||||
self.color_groups_dirty = true;
|
||||
|
||||
self.iteration(from, agents);
|
||||
self.iteration(from, competitors);
|
||||
}
|
||||
|
||||
pub(crate) fn posteriors(&self) -> HashMap<Index, Gaussian> {
|
||||
@@ -393,7 +393,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
/// Panics if an event references a competitor with no entry in this
|
||||
/// slice's skill store. `add_events` inserts one for every participant, so
|
||||
/// this cannot happen for slices built through the public API.
|
||||
pub fn iteration<D: Drift<T>>(&mut self, from: usize, agents: &CompetitorStore<T, D>) {
|
||||
pub fn iteration<D: Drift<T>>(&mut self, from: usize, competitors: &CompetitorStore<T, D>) {
|
||||
if from == 0 && self.color_groups_dirty {
|
||||
self.recompute_color_groups();
|
||||
}
|
||||
@@ -401,7 +401,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
if from > 0 || self.color_groups.is_empty() {
|
||||
// Initial pass (add_events) or no color groups yet: simple sequential sweep.
|
||||
for event in self.events.iter_mut().skip(from) {
|
||||
let teams = event.within_priors(false, &self.skills, agents);
|
||||
let teams = event.within_priors(false, &self.skills, competitors);
|
||||
let result = event.outputs();
|
||||
|
||||
let g = match event.kind {
|
||||
@@ -436,14 +436,14 @@ impl<T: Time> TimeSlice<T> {
|
||||
event.log_evidence = g.log_evidence;
|
||||
}
|
||||
} else {
|
||||
self.sweep_color_groups(agents);
|
||||
self.sweep_color_groups(competitors);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full event sweep using the color-group partition. Colors are processed
|
||||
/// sequentially; within each color the inner loop is parallel under rayon.
|
||||
///
|
||||
/// Events in one color group touch disjoint agent sets, so none of them
|
||||
/// Events in one color group touch disjoint competitor sets, so none of them
|
||||
/// can observe another's writes. That makes the sweep separable: inference
|
||||
/// runs concurrently over shared `&self.skills`, and the resulting updates
|
||||
/// are folded in afterwards in index order. Splitting it this way needs no
|
||||
@@ -451,7 +451,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
/// across thread counts because the apply order does not depend on which
|
||||
/// worker finished first.
|
||||
#[cfg(feature = "rayon")]
|
||||
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
fn sweep_color_groups<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||
use rayon::prelude::*;
|
||||
|
||||
thread_local! {
|
||||
@@ -483,7 +483,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
let mut arena = cell.borrow_mut();
|
||||
arena.reset();
|
||||
|
||||
ev.compute(skills, agents, p_draw, convergence, &mut arena)
|
||||
ev.compute(skills, competitors, p_draw, convergence, &mut arena)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -495,7 +495,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
for ev in &mut self.events[range] {
|
||||
ev.iteration_direct(
|
||||
&mut self.skills,
|
||||
agents,
|
||||
competitors,
|
||||
p_draw,
|
||||
self.convergence,
|
||||
&mut self.arena,
|
||||
@@ -509,7 +509,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
/// Events within each color group are updated inline — no EventOutput allocation —
|
||||
/// matching the T2 performance profile.
|
||||
#[cfg(not(feature = "rayon"))]
|
||||
fn sweep_color_groups<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
fn sweep_color_groups<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||
for color_idx in 0..self.color_groups.groups.len() {
|
||||
if self.color_groups.groups[color_idx].is_empty() {
|
||||
continue;
|
||||
@@ -523,7 +523,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
for ev in &mut self.events[range] {
|
||||
ev.iteration_direct(
|
||||
&mut self.skills,
|
||||
agents,
|
||||
competitors,
|
||||
p_draw,
|
||||
self.convergence,
|
||||
&mut self.arena,
|
||||
@@ -544,7 +544,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
/// schedule default.
|
||||
pub(crate) fn iterate_to_convergence<D: Drift<T>>(
|
||||
&mut self,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
) -> usize {
|
||||
use crate::{tuple_gt, tuple_max};
|
||||
|
||||
@@ -557,7 +557,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
while tuple_gt(step, epsilon) && i < max_iter {
|
||||
let old = self.posteriors();
|
||||
|
||||
self.iteration(0, agents);
|
||||
self.iteration(0, competitors);
|
||||
|
||||
let new = self.posteriors();
|
||||
|
||||
@@ -575,37 +575,37 @@ impl<T: Time> TimeSlice<T> {
|
||||
i
|
||||
}
|
||||
|
||||
pub(crate) fn forward_prior_out(&self, agent: &Index) -> Gaussian {
|
||||
let skill = self.skills.get(*agent).unwrap();
|
||||
pub(crate) fn forward_prior_out(&self, competitor: &Index) -> Gaussian {
|
||||
let skill = self.skills.get(*competitor).unwrap();
|
||||
skill.forward * skill.likelihood
|
||||
}
|
||||
|
||||
pub(crate) fn backward_prior_out<D: Drift<T>>(
|
||||
&self,
|
||||
agent: &Index,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
competitor: &Index,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
) -> Gaussian {
|
||||
let skill = self.skills.get(*agent).unwrap();
|
||||
let skill = self.skills.get(*competitor).unwrap();
|
||||
let n = skill.likelihood * skill.backward;
|
||||
n.forget(
|
||||
agents[*agent]
|
||||
competitors[*competitor]
|
||||
.rating
|
||||
.drift_variance_for_elapsed(skill.elapsed),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
for (agent, skill) in self.skills.iter_mut() {
|
||||
skill.backward = agents[agent].message.unwrap_or(N_INF);
|
||||
pub(crate) fn new_backward_info<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||
for (competitor, skill) in self.skills.iter_mut() {
|
||||
skill.backward = competitors[competitor].message.unwrap_or(N_INF);
|
||||
}
|
||||
self.iteration(0, agents);
|
||||
self.iteration(0, competitors);
|
||||
}
|
||||
|
||||
pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, agents: &CompetitorStore<T, D>) {
|
||||
for (agent, skill) in self.skills.iter_mut() {
|
||||
skill.forward = agents[agent].receive_for_elapsed(skill.elapsed);
|
||||
pub(crate) fn new_forward_info<D: Drift<T>>(&mut self, competitors: &CompetitorStore<T, D>) {
|
||||
for (competitor, skill) in self.skills.iter_mut() {
|
||||
skill.forward = competitors[competitor].receive_for_elapsed(skill.elapsed);
|
||||
}
|
||||
self.iteration(0, agents);
|
||||
self.iteration(0, competitors);
|
||||
}
|
||||
|
||||
/// Run this slice's events on forward (filtering) information alone.
|
||||
@@ -618,7 +618,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
pub(crate) fn filtered_step<D: Drift<T>>(
|
||||
&self,
|
||||
incoming: &HashMap<Index, Gaussian>,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
) -> FilteredStep {
|
||||
let mut scratch = TimeSlice {
|
||||
events: self.events.clone(),
|
||||
@@ -641,16 +641,16 @@ impl<T: Time> TimeSlice<T> {
|
||||
event.log_evidence = 0.0;
|
||||
}
|
||||
|
||||
for (agent, skill) in self.skills.iter() {
|
||||
let rating = &agents[agent].rating;
|
||||
for (competitor, skill) in self.skills.iter() {
|
||||
let rating = &competitors[competitor].rating;
|
||||
|
||||
let forward = match incoming.get(&agent) {
|
||||
let forward = match incoming.get(&competitor) {
|
||||
Some(message) => message.forget(rating.drift_variance_for_elapsed(skill.elapsed)),
|
||||
None => rating.prior,
|
||||
};
|
||||
|
||||
let slot = scratch.skills.insert(
|
||||
agent,
|
||||
competitor,
|
||||
Skill {
|
||||
forward,
|
||||
backward: N_INF,
|
||||
@@ -666,19 +666,19 @@ impl<T: Time> TimeSlice<T> {
|
||||
// than leave it to be rediscovered after it breaks.
|
||||
debug_assert_eq!(
|
||||
Some(slot),
|
||||
self.skills.slot_of(agent),
|
||||
"scratch slot must match the real slice's slot for {agent:?}"
|
||||
self.skills.slot_of(competitor),
|
||||
"scratch slot must match the real slice's slot for {competitor:?}"
|
||||
);
|
||||
}
|
||||
|
||||
scratch.iterate_to_convergence(agents);
|
||||
scratch.iterate_to_convergence(competitors);
|
||||
|
||||
FilteredStep {
|
||||
log_evidence: scratch.events.iter().map(|event| event.log_evidence).sum(),
|
||||
posteriors: scratch
|
||||
.skills
|
||||
.iter()
|
||||
.map(|(agent, skill)| (agent, skill.posterior()))
|
||||
.map(|(competitor, skill)| (competitor, skill.posterior()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -687,7 +687,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
&self,
|
||||
targets: &[Index],
|
||||
forward: bool,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
) -> f64 {
|
||||
// Hashed once rather than scanned per player per event, so a
|
||||
// `log_evidence_for` with many keys is not quadratic.
|
||||
@@ -696,7 +696,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
let mut arena = ScratchArena::new();
|
||||
|
||||
let run_event = |event: &Event, arena: &mut ScratchArena| -> f64 {
|
||||
let teams = event.within_priors(forward, &self.skills, agents);
|
||||
let teams = event.within_priors(forward, &self.skills, competitors);
|
||||
let result = event.outputs();
|
||||
match event.kind {
|
||||
EventKind::Ranked => {
|
||||
@@ -741,7 +741,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
.teams
|
||||
.iter()
|
||||
.flat_map(|team| &team.items)
|
||||
.any(|item| target_set.contains(&item.agent))
|
||||
.any(|item| target_set.contains(&item.competitor))
|
||||
})
|
||||
.map(|event| run_event(event, &mut arena))
|
||||
.sum()
|
||||
@@ -753,13 +753,15 @@ impl<T: Time> TimeSlice<T> {
|
||||
.teams
|
||||
.iter()
|
||||
.flat_map(|team| &team.items)
|
||||
.any(|item| target_set.contains(&item.agent))
|
||||
.any(|item| target_set.contains(&item.competitor))
|
||||
})
|
||||
.map(|event| event.log_evidence)
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only: reads the slice's shape back for assertions.
|
||||
#[cfg(test)]
|
||||
pub fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
|
||||
self.events
|
||||
.iter()
|
||||
@@ -767,12 +769,19 @@ impl<T: Time> TimeSlice<T> {
|
||||
event
|
||||
.teams
|
||||
.iter()
|
||||
.map(|team| team.items.iter().map(|item| item.agent).collect::<Vec<_>>())
|
||||
.map(|team| {
|
||||
team.items
|
||||
.iter()
|
||||
.map(|item| item.competitor)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// Test-only: reads the slice's shape back for assertions.
|
||||
#[cfg(test)]
|
||||
pub fn get_results(&self) -> Vec<Vec<f64>> {
|
||||
self.events
|
||||
.iter()
|
||||
@@ -827,7 +836,7 @@ impl<T: Time> TimeSlice<T> {
|
||||
/// approximations that inference does not retain.
|
||||
pub(crate) fn scored_contrasts<D: Drift<T>>(
|
||||
&self,
|
||||
agents: &CompetitorStore<T, D>,
|
||||
competitors: &CompetitorStore<T, D>,
|
||||
) -> Vec<(Vec<(Index, f64)>, f64)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -853,8 +862,8 @@ impl<T: Time> TimeSlice<T> {
|
||||
for (team, sign) in [(hi, 1.0), (lo, -1.0)] {
|
||||
for (m, item) in event.teams[team].items.iter().enumerate() {
|
||||
let w = event.weights[team][m];
|
||||
noise += w * w * agents[item.agent].rating.beta.powi(2);
|
||||
contrast.push((item.agent, sign * w));
|
||||
noise += w * w * competitors[item.competitor].rating.beta.powi(2);
|
||||
contrast.push((item.competitor, sign * w));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,7 +896,7 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
KeyTable, competitor::Competitor, drift::ConstantDrift, rating::Rating,
|
||||
competitor::Competitor, drift::ConstantDrift, key_table::KeyTable, rating::Rating,
|
||||
storage::CompetitorStore,
|
||||
};
|
||||
|
||||
@@ -902,11 +911,11 @@ mod tests {
|
||||
let e = index_map.get_or_create("e");
|
||||
let f = index_map.get_or_create("f");
|
||||
|
||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
|
||||
for agent in [a, b, c, d, e, f] {
|
||||
agents.insert(
|
||||
agent,
|
||||
for competitor in [a, b, c, d, e, f] {
|
||||
competitors.insert(
|
||||
competitor,
|
||||
Competitor {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
@@ -929,7 +938,7 @@ mod tests {
|
||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||
None,
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
&competitors,
|
||||
);
|
||||
|
||||
let post = time_slice.posteriors();
|
||||
@@ -965,7 +974,7 @@ mod tests {
|
||||
epsilon = 1e-6
|
||||
);
|
||||
|
||||
assert_eq!(time_slice.iterate_to_convergence(&agents), 1);
|
||||
assert_eq!(time_slice.iterate_to_convergence(&competitors), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -979,11 +988,11 @@ mod tests {
|
||||
let e = index_map.get_or_create("e");
|
||||
let f = index_map.get_or_create("f");
|
||||
|
||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
|
||||
for agent in [a, b, c, d, e, f] {
|
||||
agents.insert(
|
||||
agent,
|
||||
for competitor in [a, b, c, d, e, f] {
|
||||
competitors.insert(
|
||||
competitor,
|
||||
Competitor {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
@@ -1006,7 +1015,7 @@ mod tests {
|
||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||
None,
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
&competitors,
|
||||
);
|
||||
|
||||
let post = time_slice.posteriors();
|
||||
@@ -1027,7 +1036,7 @@ mod tests {
|
||||
epsilon = 1e-6
|
||||
);
|
||||
|
||||
assert!(time_slice.iterate_to_convergence(&agents) > 1);
|
||||
assert!(time_slice.iterate_to_convergence(&competitors) > 1);
|
||||
|
||||
let post = time_slice.posteriors();
|
||||
|
||||
@@ -1059,11 +1068,11 @@ mod tests {
|
||||
let e = index_map.get_or_create("e");
|
||||
let f = index_map.get_or_create("f");
|
||||
|
||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
|
||||
for agent in [a, b, c, d, e, f] {
|
||||
agents.insert(
|
||||
agent,
|
||||
for competitor in [a, b, c, d, e, f] {
|
||||
competitors.insert(
|
||||
competitor,
|
||||
Competitor {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
@@ -1086,10 +1095,10 @@ mod tests {
|
||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||
None,
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
&competitors,
|
||||
);
|
||||
|
||||
time_slice.iterate_to_convergence(&agents);
|
||||
time_slice.iterate_to_convergence(&competitors);
|
||||
|
||||
let post = time_slice.posteriors();
|
||||
|
||||
@@ -1118,12 +1127,12 @@ mod tests {
|
||||
Some(vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0]]),
|
||||
None,
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
&competitors,
|
||||
);
|
||||
|
||||
assert_eq!(time_slice.events.len(), 6);
|
||||
|
||||
time_slice.iterate_to_convergence(&agents);
|
||||
time_slice.iterate_to_convergence(&competitors);
|
||||
|
||||
let post = time_slice.posteriors();
|
||||
|
||||
@@ -1162,11 +1171,11 @@ mod tests {
|
||||
let c = index_map.get_or_create("c");
|
||||
let d = index_map.get_or_create("d");
|
||||
|
||||
let mut agents: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
let mut competitors: CompetitorStore<i64, ConstantDrift> = CompetitorStore::new();
|
||||
|
||||
for agent in [a, b, c, d] {
|
||||
agents.insert(
|
||||
agent,
|
||||
for competitor in [a, b, c, d] {
|
||||
competitors.insert(
|
||||
competitor,
|
||||
Competitor {
|
||||
rating: Rating::new(
|
||||
Gaussian::from_ms(25.0, 25.0 / 3.0),
|
||||
@@ -1189,7 +1198,7 @@ mod tests {
|
||||
Some(vec![vec![1.0, 0.0], vec![1.0, 0.0], vec![1.0, 0.0]]),
|
||||
None,
|
||||
vec![EventKind::Ranked; 3],
|
||||
&agents,
|
||||
&competitors,
|
||||
);
|
||||
|
||||
assert_eq!(ts.color_groups.n_colors(), 2);
|
||||
@@ -1200,14 +1209,14 @@ mod tests {
|
||||
assert_eq!(ts.color_groups.color_range(1), 2..3);
|
||||
|
||||
// Events at positions 0 and 1 (color 0) must be disjoint — verify by
|
||||
// checking that the agent sets of self.events[0] and self.events[1] do
|
||||
// not include the agent at self.events[2].
|
||||
// checking that the competitor sets of self.events[0] and self.events[1] do
|
||||
// not include the competitor at self.events[2].
|
||||
let agents_in_ev2: Vec<Index> = ts.events[2].iter_agents().collect();
|
||||
let agents_in_ev0: Vec<Index> = ts.events[0].iter_agents().collect();
|
||||
let agents_in_ev1: Vec<Index> = ts.events[1].iter_agents().collect();
|
||||
// ev0 and ev1 must be disjoint from each other (color-0 invariant).
|
||||
assert!(agents_in_ev0.iter().all(|ag| !agents_in_ev1.contains(ag)));
|
||||
// ev2 must share an agent with ev0 or ev1 (it needed its own color).
|
||||
// ev2 must share an competitor with ev0 or ev1 (it needed its own color).
|
||||
let ev2_overlaps_ev0 = agents_in_ev2.iter().any(|ag| agents_in_ev0.contains(ag));
|
||||
let ev2_overlaps_ev1 = agents_in_ev2.iter().any(|ag| agents_in_ev1.contains(ag));
|
||||
assert!(ev2_overlaps_ev0 || ev2_overlaps_ev1);
|
||||
|
||||
@@ -54,6 +54,7 @@ fn hitting_the_cap_is_an_error() {
|
||||
iterations,
|
||||
final_step,
|
||||
epsilon,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(iterations, 1);
|
||||
assert!(
|
||||
|
||||
@@ -160,6 +160,7 @@ fn event_builder_rejects_a_weights_length_mismatch() {
|
||||
kind: "weights",
|
||||
expected: 1,
|
||||
got: 2,
|
||||
..
|
||||
}
|
||||
),
|
||||
"expected a weights MismatchedShape, got {err:?}"
|
||||
|
||||
@@ -277,13 +277,11 @@ fn reject(scale: f64) -> InferenceError {
|
||||
|
||||
#[test]
|
||||
fn negative_scale_is_rejected() {
|
||||
assert_eq!(
|
||||
assert!(matches!(
|
||||
reject(-1.0),
|
||||
InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
value: -1.0
|
||||
}
|
||||
);
|
||||
InferenceError::InvalidParameter { name: "drift_scale", value, .. }
|
||||
if value == -1.0
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -135,7 +135,8 @@ fn weights_still_guards_a_members_team() {
|
||||
InferenceError::MismatchedShape {
|
||||
kind: "weights",
|
||||
expected: 2,
|
||||
got: 1
|
||||
got: 1,
|
||||
..
|
||||
}
|
||||
),
|
||||
"{err:?}"
|
||||
|
||||
+4
-4
@@ -155,7 +155,7 @@ mod malformed_games {
|
||||
let err = Game::<i64, _>::ranked(&[&[a]], Outcome::winner(0, 1), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -173,7 +173,7 @@ mod malformed_games {
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -184,7 +184,7 @@ mod malformed_games {
|
||||
Game::<i64, ConstantDrift>::ranked(&[], Outcome::ranking([]), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -198,7 +198,7 @@ mod malformed_games {
|
||||
Game::<i64, _>::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0 }),
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ fn a_one_team_event_is_an_error_not_a_panic() {
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ fn a_zero_team_event_is_an_error() {
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 0, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ fn an_empty_team_is_an_error_rather_than_a_free_win() {
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0 }),
|
||||
matches!(err, InferenceError::EmptyTeam { team: 0, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
// Nothing was recorded, so the history is still empty.
|
||||
@@ -93,7 +93,7 @@ fn an_empty_team_is_reported_by_position() {
|
||||
}])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::EmptyTeam { team: 1 }),
|
||||
matches!(err, InferenceError::EmptyTeam { team: 1, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
@@ -170,7 +170,7 @@ fn the_event_builder_inherits_the_shape_checks() {
|
||||
let mut h = history();
|
||||
let err = h.event(1).team(["a"]).winner(0).commit().unwrap_err();
|
||||
assert!(
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1 }),
|
||||
matches!(err, InferenceError::NotEnoughTeams { got: 1, .. }),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ fn overflow_during_inference_is_reported_not_hidden() {
|
||||
|
||||
for (name, sigma, beta, score_sigma, scores) in cases {
|
||||
match scored_fit(sigma, beta, score_sigma, scores) {
|
||||
Err(InferenceError::NonFiniteResult { context, step }) => {
|
||||
Err(InferenceError::NonFiniteResult { context, step, .. }) => {
|
||||
assert_eq!(context, "History::converge", "{name}");
|
||||
assert!(
|
||||
!step.0.is_finite() || !step.1.is_finite(),
|
||||
|
||||
@@ -148,6 +148,6 @@ fn shape_errors_are_reported() {
|
||||
let empty: [&&str; 0] = [];
|
||||
assert!(matches!(
|
||||
h.predict_margin(&[&[&"veteran"], &empty]),
|
||||
Err(InferenceError::EmptyTeam { team: 1 })
|
||||
Err(InferenceError::EmptyTeam { team: 1, .. })
|
||||
));
|
||||
}
|
||||
|
||||
+31
-37
@@ -20,13 +20,13 @@ fn unknown_keys_are_reported_not_silently_dropped() {
|
||||
let err = h
|
||||
.predict_outcome(&[&[&"a"], &[&"ghost"]])
|
||||
.expect_err("an unknown key must not yield a confident prediction");
|
||||
assert_eq!(
|
||||
err,
|
||||
InferenceError::UnknownKey {
|
||||
team: 1,
|
||||
member: 0,
|
||||
key: "\"ghost\"".to_owned(),
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
&err,
|
||||
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||
if key == "\"ghost\""
|
||||
),
|
||||
"{err:?}"
|
||||
);
|
||||
|
||||
// Every prediction entry point, not just one.
|
||||
@@ -42,13 +42,13 @@ fn unknown_keys_are_reported_not_silently_dropped() {
|
||||
fn an_entirely_unknown_team_is_an_error() {
|
||||
let h = history_with(&["a", "b"], 0.0);
|
||||
let err = h.predict_outcome(&[&[&"a"], &[&"x", &"y"]]).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
InferenceError::UnknownKey {
|
||||
team: 1,
|
||||
member: 0,
|
||||
key: "\"x\"".to_owned(),
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
&err,
|
||||
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||
if key == "\"x\""
|
||||
),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,18 +56,18 @@ fn an_entirely_unknown_team_is_an_error() {
|
||||
fn degenerate_team_shapes_are_errors_rather_than_panics() {
|
||||
let h = history_with(&["a", "b"], 0.0);
|
||||
|
||||
assert_eq!(
|
||||
assert!(matches!(
|
||||
h.predict_outcome(&[&[&"a"]]).unwrap_err(),
|
||||
InferenceError::NotEnoughTeams { got: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
InferenceError::NotEnoughTeams { got: 1, .. }
|
||||
),);
|
||||
assert!(matches!(
|
||||
h.predict_outcome(&[]).unwrap_err(),
|
||||
InferenceError::NotEnoughTeams { got: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
InferenceError::NotEnoughTeams { got: 0, .. }
|
||||
),);
|
||||
assert!(matches!(
|
||||
h.predict_outcome(&[&[&"a"], &[]]).unwrap_err(),
|
||||
InferenceError::EmptyTeam { team: 1 }
|
||||
);
|
||||
InferenceError::EmptyTeam { team: 1, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -93,13 +93,10 @@ fn the_outcome_space_is_capped_rather_than_hanging() {
|
||||
let refs: Vec<&[&&str]> = too_many.iter().map(Vec::as_slice).collect();
|
||||
|
||||
let err = h.predict_outcome(&refs).unwrap_err();
|
||||
assert_eq!(
|
||||
assert!(matches!(
|
||||
err,
|
||||
InferenceError::TooManyTeams {
|
||||
got: 8,
|
||||
max: MAX_PREDICTED_TEAMS
|
||||
}
|
||||
);
|
||||
InferenceError::TooManyTeams { got: 8, max, .. } if max == MAX_PREDICTED_TEAMS
|
||||
));
|
||||
|
||||
// The cheap paths stay available at any size.
|
||||
let wins = h.predict_win_probabilities(&refs).unwrap();
|
||||
@@ -282,15 +279,12 @@ fn information_gain_respects_the_entropy_ceiling() {
|
||||
#[test]
|
||||
fn information_gain_reports_unknown_keys() {
|
||||
let h = history_with(&["a", "b"], 0.0);
|
||||
assert_eq!(
|
||||
h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
|
||||
assert!(matches!(
|
||||
&h.expected_information_gain(&[&[&"a"], &[&"ghost"]])
|
||||
.unwrap_err(),
|
||||
InferenceError::UnknownKey {
|
||||
team: 1,
|
||||
member: 0,
|
||||
key: "\"ghost\"".to_owned(),
|
||||
}
|
||||
);
|
||||
InferenceError::UnknownKey { team: 1, member: 0, key, .. }
|
||||
if key == "\"ghost\""
|
||||
));
|
||||
}
|
||||
|
||||
/// A draw-enabled history has three outcomes to weigh rather than two, so the
|
||||
|
||||
@@ -154,7 +154,7 @@ fn the_known_ceiling_violation_no_longer_answers_wrongly() {
|
||||
gain <= 2.0_f64.ln() + 1e-9,
|
||||
"returned {gain}, over the ln 2 ceiling"
|
||||
),
|
||||
Err(InferenceError::GridTooCoarse { needed, max }) => {
|
||||
Err(InferenceError::GridTooCoarse { needed, max, .. }) => {
|
||||
assert!(needed > max, "needed {needed} should exceed max {max}");
|
||||
}
|
||||
Err(e) => panic!("unexpected error {e:?}"),
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
//! `quality()` beyond two rating groups.
|
||||
//! `quality()` beyond two teams.
|
||||
//!
|
||||
//! The historical golden (two equal singletons) is asserted in
|
||||
//! `src/lib.rs::tests::test_quality`. These cover the N-group generalisation,
|
||||
@@ -82,14 +82,14 @@ fn uneven_group_sizes_work() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "at least 2 rating groups")]
|
||||
#[should_panic(expected = "at least 2 teams")]
|
||||
fn single_group_panics_with_clear_message() {
|
||||
let r = rating(25.0, 3.0);
|
||||
let _ = quality(&[&[r]], BETA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "at least 2 rating groups")]
|
||||
#[should_panic(expected = "at least 2 teams")]
|
||||
fn zero_groups_panics_with_clear_message() {
|
||||
let _ = quality(&[], BETA);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user