refactor!: un-export six types that no caller could reach

`TimeSlice`, `EventKind`, `KeyTable`, `CompetitorStore`, `Competitor` and
the `storage` module were all public and none was obtainable from a
`History` — `time_slices`, `agents` and `keys` are all private or
`pub(crate)`. `TimeSlice` was the worst: `new`, `add_events`, `iteration`,
`get_composition` and `get_results` were `pub` on a type you could only
build standalone and never feed back into anything.

Their sole consumer outside `src/` was `benches/batch.rs`, so a benchmark
was dictating six public types. It is rewritten against the public API: a
single-slice history's `converge` calls exactly the same per-slice sweep,
so capping at one iteration measures the same code path.

`N01` had zero references in the entire repository, including inside the
crate; removed. `N00` and `N_INF` are EP identities (`Add` and `Mul`) and
are now `pub(crate)` — a user reaching for `N_INF` as "an unknown
competitor's prior" would get an improper distribution whose `mu()`
silently reports 0.0.

Adds the accessors their absence forced people around, from #70:
`competitors()`, `competitor_count()` and `event_count()` (`size` had no
accessor at all). Answering "who is best" previously meant materialising
every competitor's full smoothed curve to read the last point of each.

`KeyTable::keys` now iterates the dense reverse table rather than the
forward `HashMap`, so `competitors()` yields insertion order rather than
per-process hash order — the same hazard as #62, caught before it could
reach a caller building a standings table.

Two `CompetitorStore` methods (`is_empty`, `iter_mut`) had no callers
anywhere and are gone; four more are now `#[cfg(test)]`, which is what
they always were in practice.

Worth recording a mistake: I first deleted `get_composition`/`get_results`
on the strength of a "never used" warning, and the build broke — the
warning came from the plain-lib target, where `#[cfg(test)]` callers in
history.rs are not compiled. A dead-code warning from one target is not
evidence about the others.

BREAKING CHANGE: `TimeSlice`, `EventKind`, `KeyTable`, `CompetitorStore`,
`Competitor`, the `storage` module, `N01`, `N00` and `N_INF` are no longer
public.

Refs #73, #70

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
2026-09-09 20:34:18 +02:00
co-authored by Claude Opus 5
parent 5f5a37090a
commit 4472d98b56
6 changed files with 99 additions and 64 deletions
+33
View File
@@ -546,6 +546,39 @@ 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>(())
/// ```
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.
pub fn learning_curves(&self) -> HashMap<K, Vec<(T, Gaussian)>> {
#[cfg(feature = "rayon")]
+8 -7
View File
@@ -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>
+3 -7
View File
@@ -109,7 +109,6 @@ mod approx;
pub(crate) mod arena;
mod time;
mod time_slice;
pub use time_slice::{EventKind, TimeSlice};
mod acquisition;
mod color_group;
mod competitor;
@@ -130,10 +129,9 @@ mod outcome;
mod predict;
pub(crate) mod quadrature;
mod rating;
pub mod storage;
pub(crate) mod storage;
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);
+5 -12
View File
@@ -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())
}
+5 -1
View File
@@ -760,6 +760,8 @@ impl<T: Time> TimeSlice<T> {
}
}
/// Test-only: reads the slice's shape back for assertions.
#[cfg(test)]
pub fn get_composition(&self) -> Vec<Vec<Vec<Index>>> {
self.events
.iter()
@@ -773,6 +775,8 @@ impl<T: Time> TimeSlice<T> {
.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()
@@ -887,7 +891,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,
};