diff --git a/benches/batch.rs b/benches/batch.rs index 20bd6c7..1f2d4d2 100644 --- a/benches/batch.rs +++ b/benches/batch.rs @@ -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> = (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 = 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)) }); } diff --git a/src/convergence.rs b/src/convergence.rs index e7041fd..ee7e87a 100644 --- a/src/convergence.rs +++ b/src/convergence.rs @@ -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), diff --git a/src/error.rs b/src/error.rs index 8f24478..f1f04f2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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 }, } diff --git a/src/event.rs b/src/event.rs index f547cc3..b36278c 100644 --- a/src/event.rs +++ b/src/event.rs @@ -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 { pub time: T, pub teams: SmallVec<[Team; 4]>, @@ -19,7 +19,7 @@ pub struct Event { } /// A team: list of members competing together. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Team { pub members: SmallVec<[Member; 4]>, } @@ -61,7 +61,7 @@ impl Default for Team { /// 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 { pub key: K, pub weight: f64, diff --git a/src/event_builder.rs b/src/event_builder.rs index ecc1555..6ed9255 100644 --- a/src/event_builder.rs +++ b/src/event_builder.rs @@ -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, diff --git a/src/factor/mod.rs b/src/factor/mod.rs index 4578755..8b84c3d 100644 --- a/src/factor/mod.rs +++ b/src/factor/mod.rs @@ -44,7 +44,6 @@ impl VarStore { id } - #[must_use] pub fn get(&self, id: VarId) -> Gaussian { self.marginals[id.0 as usize] } diff --git a/src/game.rs b/src/game.rs index 8c97fed..b0d7e67 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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> { teams: Vec>>, pub(crate) likelihoods: Vec>, @@ -283,7 +284,9 @@ impl<'a, T: Time, D: Drift> 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> 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::>() }) @@ -576,7 +579,7 @@ impl> 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> 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], + competitors: &[&Rating], outcome: crate::Outcome, options: &GameOptions, ) -> Result, crate::InferenceError> { - let teams: Vec>> = players.iter().map(|p| vec![**p]).collect(); + let teams: Vec>> = competitors.iter().map(|p| vec![**p]).collect(); let team_refs: Vec<&[Rating]> = 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 = (0..4).map(|_| R::default()).collect(); - let teams: Vec> = players.iter().map(|p| vec![*p]).collect(); + let competitors: Vec = (0..4).map(|_| R::default()).collect(); + let teams: 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 = (0..4).map(|_| R::default()).collect(); - let teams: Vec> = players.iter().map(|p| vec![*p]).collect(); + let competitors: Vec = (0..4).map(|_| R::default()).collect(); + let teams: 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]; diff --git a/src/gaussian.rs b/src/gaussian.rs index 08ba486..58a1884 100644 --- a/src/gaussian.rs +++ b/src/gaussian.rs @@ -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(), diff --git a/src/history.rs b/src/history.rs index b2ae468..cd2e2bc 100644 --- a/src/history.rs +++ b/src/history.rs @@ -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 = ConstantDrift, @@ -197,7 +198,6 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< /// assert!(h.current_skill(&"alice").unwrap().mu() > 0.0); /// # Ok::<(), trueskill_tt::InferenceError>(()) /// ``` - #[must_use] pub fn time_type(self) -> HistoryBuilder where T2: Time, @@ -232,7 +232,6 @@ impl, O: Observer, 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(self) -> HistoryBuilder { HistoryBuilder { mu: self.mu, @@ -269,7 +268,7 @@ impl, O: Observer, 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>, - pub(crate) agents: CompetitorStore, + pub(crate) competitors: CompetitorStore, keys: KeyTable, mu: f64, sigma: f64, @@ -418,7 +417,6 @@ impl Default for History { } impl History { - #[must_use] pub fn builder() -> HistoryBuilder { HistoryBuilder::default() } @@ -440,7 +438,6 @@ impl HistoryBuilder(()) /// ``` - #[must_use] pub fn new() -> Self { Self::default() } @@ -455,6 +452,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History(&self, key: &Q) -> Option where K: Borrow, @@ -472,17 +470,18 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History = h.competitors().copied().collect(); + /// assert_eq!(names, ["alice", "bob"]); + /// # Ok::<(), trueskill_tt::InferenceError>(()) + /// ``` + #[must_use] + pub fn competitors(&self) -> impl ExactSizeIterator { + 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> { #[cfg(feature = "rayon")] { @@ -643,7 +677,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History(&self, key: &Q) -> Option where K: std::borrow::Borrow, @@ -708,6 +745,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History(&self, key: &Q) -> Vec<(T, Gaussian)> where K: std::borrow::Borrow, @@ -731,12 +769,13 @@ impl, O: Observer, K: Eq + Hash + Clone> History HashMap> { let mut data: HashMap> = 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, O: Observer, K: Eq + Hash + Clone> History(&self, key: &Q) -> Vec<(T, Gaussian)> where K: Borrow, @@ -767,7 +807,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History` 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, O: Observer, K: Eq + Hash + Clone> History = 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, O: Observer, K: Eq + Hash + Clone> History 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(&self, keys: &[&Q]) -> f64 where K: std::borrow::Borrow, @@ -833,10 +875,10 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History 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, O: Observer, K: Eq + Hash + Clone> History { let row = n; n += 1; - first_rows.push((row, agent)); + first_rows.push((row, competitor)); row } Some(&prev) => { @@ -1104,16 +1146,17 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History { + crate::UnknownKeys::Reject => { return Err(InferenceError::UnknownKey { team: 0, member, @@ -1443,7 +1486,8 @@ impl, O: Observer, K: Eq + Hash + Clone> History Result where K: std::fmt::Debug, @@ -1471,7 +1515,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History Result where K: std::fmt::Debug, @@ -1613,6 +1659,8 @@ impl, O: Observer, K: Eq + Hash + Clone> History Result where K: std::fmt::Debug, @@ -1641,8 +1689,6 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History Result where K: std::fmt::Debug, @@ -1700,6 +1749,10 @@ impl, O: Observer, K: Eq + Hash + Clone> History Result { let report = self.converge_partial()?; @@ -1726,6 +1779,8 @@ impl, O: Observer, K: Eq + Hash + Clone> History Result { use std::time::Instant; @@ -1744,8 +1799,8 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History = 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, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History 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, O: Observer, K: Eq + Hash + Clone> History 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, O: Observer, K: Eq + Hash + Clone> History 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, O: Observer, K: Eq + Hash + Clone> History= 3`, which ties every loser. @@ -2379,6 +2454,33 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> std::fmt::Debug + for History +{ + 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, O: Observer, 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, O: Observer, 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:?}"); } } } diff --git a/src/key_table.rs b/src/key_table.rs index 40953d6..b06e526 100644 --- a/src/key_table.rs +++ b/src/key_table.rs @@ -60,19 +60,20 @@ where self.reverse.get(idx.0) } - pub fn keys(&self) -> impl Iterator { - 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 { + 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 Default for KeyTable diff --git a/src/lib.rs b/src/lib.rs index 1feca81..91b1585 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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(xs: &[T], reverse: bool) -> Vec { 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(xs: &[T], reverse: bool) -> Vec { /// 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::>(); @@ -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]; diff --git a/src/outcome.rs b/src/outcome.rs index 526f595..0546b9b 100644 --- a/src/outcome.rs +++ b/src/outcome.rs @@ -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 diff --git a/src/predict.rs b/src/predict.rs index ac7cfe4..081979f 100644 --- a/src/predict.rs +++ b/src/predict.rs @@ -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, f64)>, } diff --git a/src/rating.rs b/src/rating.rs index a58fabc..f7faef3 100644 --- a/src/rating.rs +++ b/src/rating.rs @@ -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 = ConstantDrift> { pub(crate) prior: Gaussian, pub(crate) beta: f64, @@ -61,7 +61,6 @@ impl> Rating { } /// The configured prior skill estimate. - #[must_use] pub fn prior(&self) -> Gaussian { self.prior } diff --git a/src/storage/competitor_store.rs b/src/storage/competitor_store.rs index 6ac7789..c47314a 100644 --- a/src/storage/competitor_store.rs +++ b/src/storage/competitor_store.rs @@ -56,16 +56,16 @@ impl> CompetitorStore { 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)> { self.competitors .iter() @@ -73,13 +73,6 @@ impl> CompetitorStore { .filter_map(|(i, slot)| slot.as_ref().map(|a| (Index(i), a))) } - pub fn iter_mut(&mut self) -> impl Iterator)> { - 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> { self.competitors.iter_mut().filter_map(|s| s.as_mut()) } diff --git a/src/time_slice.rs b/src/time_slice.rs index b00fd11..681eb5d 100644 --- a/src/time_slice.rs +++ b/src/time_slice.rs @@ -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, + competitors: &CompetitorStore, ) -> Rating { - 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 + '_ { 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 { @@ -112,14 +112,14 @@ impl Event { &self, forward: bool, skills: &SkillStore, - agents: &CompetitorStore, + competitors: &CompetitorStore, ) -> Vec>> { 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::>() }) .collect::>() @@ -133,12 +133,12 @@ impl Event { fn compute>( &self, skills: &SkillStore, - agents: &CompetitorStore, + competitors: &CompetitorStore, 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>( &mut self, skills: &mut SkillStore, - agents: &CompetitorStore, + competitors: &CompetitorStore, 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 TimeSlice { results: Option>>, weights: Option>>>, kinds: Vec, - agents: &CompetitorStore, + competitors: &CompetitorStore, ) { let mut unique = Vec::with_capacity(10); @@ -303,9 +303,9 @@ impl TimeSlice { }); 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 TimeSlice { .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 TimeSlice { self.color_groups_dirty = true; - self.iteration(from, agents); + self.iteration(from, competitors); } pub(crate) fn posteriors(&self) -> HashMap { @@ -393,7 +393,7 @@ impl TimeSlice { /// 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>(&mut self, from: usize, agents: &CompetitorStore) { + pub fn iteration>(&mut self, from: usize, competitors: &CompetitorStore) { if from == 0 && self.color_groups_dirty { self.recompute_color_groups(); } @@ -401,7 +401,7 @@ impl TimeSlice { 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 TimeSlice { 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 TimeSlice { /// across thread counts because the apply order does not depend on which /// worker finished first. #[cfg(feature = "rayon")] - fn sweep_color_groups>(&mut self, agents: &CompetitorStore) { + fn sweep_color_groups>(&mut self, competitors: &CompetitorStore) { use rayon::prelude::*; thread_local! { @@ -483,7 +483,7 @@ impl TimeSlice { 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 TimeSlice { 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 TimeSlice { /// Events within each color group are updated inline — no EventOutput allocation — /// matching the T2 performance profile. #[cfg(not(feature = "rayon"))] - fn sweep_color_groups>(&mut self, agents: &CompetitorStore) { + fn sweep_color_groups>(&mut self, competitors: &CompetitorStore) { 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 TimeSlice { 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 TimeSlice { /// schedule default. pub(crate) fn iterate_to_convergence>( &mut self, - agents: &CompetitorStore, + competitors: &CompetitorStore, ) -> usize { use crate::{tuple_gt, tuple_max}; @@ -557,7 +557,7 @@ impl TimeSlice { 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 TimeSlice { 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>( &self, - agent: &Index, - agents: &CompetitorStore, + competitor: &Index, + competitors: &CompetitorStore, ) -> 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>(&mut self, agents: &CompetitorStore) { - for (agent, skill) in self.skills.iter_mut() { - skill.backward = agents[agent].message.unwrap_or(N_INF); + pub(crate) fn new_backward_info>(&mut self, competitors: &CompetitorStore) { + 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>(&mut self, agents: &CompetitorStore) { - for (agent, skill) in self.skills.iter_mut() { - skill.forward = agents[agent].receive_for_elapsed(skill.elapsed); + pub(crate) fn new_forward_info>(&mut self, competitors: &CompetitorStore) { + 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 TimeSlice { pub(crate) fn filtered_step>( &self, incoming: &HashMap, - agents: &CompetitorStore, + competitors: &CompetitorStore, ) -> FilteredStep { let mut scratch = TimeSlice { events: self.events.clone(), @@ -641,16 +641,16 @@ impl TimeSlice { 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 TimeSlice { // 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 TimeSlice { &self, targets: &[Index], forward: bool, - agents: &CompetitorStore, + competitors: &CompetitorStore, ) -> 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 TimeSlice { 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 TimeSlice { .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 TimeSlice { .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>> { self.events .iter() @@ -767,12 +769,19 @@ impl TimeSlice { event .teams .iter() - .map(|team| team.items.iter().map(|item| item.agent).collect::>()) + .map(|team| { + team.items + .iter() + .map(|item| item.competitor) + .collect::>() + }) .collect::>() }) .collect::>() } + /// Test-only: reads the slice's shape back for assertions. + #[cfg(test)] pub fn get_results(&self) -> Vec> { self.events .iter() @@ -827,7 +836,7 @@ impl TimeSlice { /// approximations that inference does not retain. pub(crate) fn scored_contrasts>( &self, - agents: &CompetitorStore, + competitors: &CompetitorStore, ) -> Vec<(Vec<(Index, f64)>, f64)> { let mut out = Vec::new(); @@ -853,8 +862,8 @@ impl TimeSlice { 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 = CompetitorStore::new(); + let mut competitors: CompetitorStore = 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 = CompetitorStore::new(); + let mut competitors: CompetitorStore = 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 = CompetitorStore::new(); + let mut competitors: CompetitorStore = 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 = CompetitorStore::new(); + let mut competitors: CompetitorStore = 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 = ts.events[2].iter_agents().collect(); let agents_in_ev0: Vec = ts.events[0].iter_agents().collect(); let agents_in_ev1: Vec = 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); diff --git a/tests/convergence_strictness.rs b/tests/convergence_strictness.rs index 03ab150..0910cf9 100644 --- a/tests/convergence_strictness.rs +++ b/tests/convergence_strictness.rs @@ -54,6 +54,7 @@ fn hitting_the_cap_is_an_error() { iterations, final_step, epsilon, + .. } => { assert_eq!(iterations, 1); assert!( diff --git a/tests/degenerate_inputs.rs b/tests/degenerate_inputs.rs index e3e4ef7..0510630 100644 --- a/tests/degenerate_inputs.rs +++ b/tests/degenerate_inputs.rs @@ -160,6 +160,7 @@ fn event_builder_rejects_a_weights_length_mismatch() { kind: "weights", expected: 1, got: 2, + .. } ), "expected a weights MismatchedShape, got {err:?}" diff --git a/tests/drift_scale.rs b/tests/drift_scale.rs index 310a687..934fcb4 100644 --- a/tests/drift_scale.rs +++ b/tests/drift_scale.rs @@ -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] diff --git a/tests/event_builder_members.rs b/tests/event_builder_members.rs index f182ab0..cd098eb 100644 --- a/tests/event_builder_members.rs +++ b/tests/event_builder_members.rs @@ -135,7 +135,8 @@ fn weights_still_guards_a_members_team() { InferenceError::MismatchedShape { kind: "weights", expected: 2, - got: 1 + got: 1, + .. } ), "{err:?}" diff --git a/tests/game.rs b/tests/game.rs index 5330523..6681990 100644 --- a/tests/game.rs +++ b/tests/game.rs @@ -155,7 +155,7 @@ mod malformed_games { let err = Game::::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::::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::::ranked(&[&[], &[a]], Outcome::winner(0, 2), &GameOptions::default()) .unwrap_err(); assert!( - matches!(err, InferenceError::EmptyTeam { team: 0 }), + matches!(err, InferenceError::EmptyTeam { team: 0, .. }), "{err:?}" ); } diff --git a/tests/ingestion_shape.rs b/tests/ingestion_shape.rs index cd43ab4..5c2e43f 100644 --- a/tests/ingestion_shape.rs +++ b/tests/ingestion_shape.rs @@ -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:?}" ); } diff --git a/tests/non_finite_results.rs b/tests/non_finite_results.rs index 26f7424..978b7b6 100644 --- a/tests/non_finite_results.rs +++ b/tests/non_finite_results.rs @@ -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(), diff --git a/tests/predict_margin.rs b/tests/predict_margin.rs index 95b70c5..c85866a 100644 --- a/tests/predict_margin.rs +++ b/tests/predict_margin.rs @@ -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, .. }) )); } diff --git a/tests/prediction.rs b/tests/prediction.rs index d3595ea..5a0f792 100644 --- a/tests/prediction.rs +++ b/tests/prediction.rs @@ -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 diff --git a/tests/prediction_bounds.rs b/tests/prediction_bounds.rs index 0b9267e..307f8c6 100644 --- a/tests/prediction_bounds.rs +++ b/tests/prediction_bounds.rs @@ -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:?}"), diff --git a/tests/quality.rs b/tests/quality.rs index cbc15fe..15b76a6 100644 --- a/tests/quality.rs +++ b/tests/quality.rs @@ -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); }