80 undocumented public items, including three that are first contact:
`History::current_skill` — the method the crate's own first example calls
— `EventBuilder`, the type `h.event(t)` hands you, and `Gaussian::mu()`.
Now zero, and `#![deny(missing_docs)]` keeps it that way.
Several docs are measurements rather than readings of the code:
- `Outcome::Ranked` says ranks are used ordinally, so `[0, 1, 2]` and
`[0, 5, 90]` are the same observation. Measured: bit-identical
posteriors for both.
- `OwnedGame::log_evidence` says two identically-rated competitors give
exactly `ln(0.5)`. Written as a doctest, so it runs.
- `Member::weight` says zero and negative are accepted. Measured.
- `ConvergenceReport::final_step` is `(|Δmu|, |Δsigma|)` in skill units,
NOT natural parameters. That one had to be traced through
`Gaussian::delta` rather than assumed from the neighbouring vocabulary.
- `GameOptions::score_sigma` rejects non-positive and NaN but accepts
`+inf`, which is what the guard actually says.
README: it is the front door for a crate on a private registry, and it
opened with a link dump followed by 130 lines on drift. The first
`record_winner → converge → current_skill` block was at line 226 of 307.
It now leads with what the crate is, an install line, a quickstart, a
"which entry point?" table, and the `converge`-is-strict rationale that
was the crate's most opinionated recent decision and went unmentioned.
The two canonical examples disagreed on spelling (`History::default()`
vs `History::builder().build()`, `current_skill("a")` vs
`current_skill(&"a")`); they now agree. Five new README blocks are
doctested, taking the suite from 19 to 25.
`pub use smallvec;`. Four public items name `SmallVec` in their
signatures, and the only `Joint` example failed to compile from a
consumer crate with `unresolved import smallvec` — the dependency was in
the API but not reachable. Both worked examples now use the re-export,
so they teach the path that works downstream.
Vocabulary, from #75: "agent" was a fourth word for competitor, 200
occurrences, and it had reached public signatures before #73 un-exported
`TimeSlice`. Now zero.
Closes #77. Refs #75.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
211 lines
7.5 KiB
Rust
211 lines
7.5 KiB
Rust
use smallvec::SmallVec;
|
|
|
|
use crate::{
|
|
InferenceError, Outcome,
|
|
drift::Drift,
|
|
event::{Event, Member, Team},
|
|
history::History,
|
|
observer::Observer,
|
|
time::Time,
|
|
};
|
|
|
|
/// One match under construction, handed back by [`History::event`].
|
|
///
|
|
/// Describes a single event a piece at a time — teams, then per-member weights
|
|
/// if they differ, then how it ended — instead of assembling an
|
|
/// [`Event`] value and passing it to [`History::add_events`]. The two routes
|
|
/// ingest through the same chokepoint and accept the same things; this one just
|
|
/// reads better for a single match written by hand.
|
|
///
|
|
/// The builder borrows the history mutably and nothing reaches it until
|
|
/// [`EventBuilder::commit`]. A builder that is dropped instead ingests
|
|
/// nothing at all, silently — hence the `#[must_use]`, which is the only
|
|
/// warning you get. `commit` is also where validation surfaces: the setters
|
|
/// return `Self` to keep the chain fluent, so a mismatch such as a weight list
|
|
/// the wrong length is recorded while building and returned as an error from
|
|
/// `commit`.
|
|
///
|
|
/// ```
|
|
/// # use trueskill_tt::History;
|
|
/// let mut h = History::builder().build();
|
|
/// h.event(1)
|
|
/// .team(["alice", "bob"])
|
|
/// .team(["carol"])
|
|
/// .ranking([0, 1])
|
|
/// .commit()?;
|
|
/// assert_eq!(h.event_count(), 1);
|
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
|
/// ```
|
|
#[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,
|
|
D: Drift<T>,
|
|
O: Observer<T>,
|
|
K: Eq + std::hash::Hash + Clone,
|
|
{
|
|
history: &'h mut History<T, D, O, K>,
|
|
event: Event<T, K>,
|
|
current_team_idx: Option<usize>,
|
|
/// First validation failure seen while building, surfaced by `commit`.
|
|
///
|
|
/// The setters return `Self` so the chain stays fluent; they cannot return
|
|
/// a `Result` without breaking that. Recording the failure and reporting it
|
|
/// at `commit` keeps the check enforced in release, where the previous
|
|
/// `debug_assert!` was compiled out and a mismatched event was ingested
|
|
/// silently.
|
|
error: Option<InferenceError>,
|
|
}
|
|
|
|
impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K>
|
|
where
|
|
T: Time,
|
|
D: Drift<T>,
|
|
O: Observer<T>,
|
|
K: Eq + std::hash::Hash + Clone,
|
|
{
|
|
pub(crate) fn new(history: &'h mut History<T, D, O, K>, time: T) -> Self {
|
|
Self {
|
|
history,
|
|
event: Event {
|
|
time,
|
|
teams: SmallVec::new(),
|
|
outcome: Outcome::Ranked(SmallVec::new()),
|
|
},
|
|
current_team_idx: None,
|
|
error: None,
|
|
}
|
|
}
|
|
|
|
/// Add a team by its member keys (weight 1.0 each, no prior overrides).
|
|
///
|
|
/// Use [`EventBuilder::members`] to set `prior` or `drift_scale`.
|
|
pub fn team<I: IntoIterator<Item = K>>(mut self, keys: I) -> Self {
|
|
let members: SmallVec<[Member<K>; 4]> = keys.into_iter().map(Member::new).collect();
|
|
self.event.teams.push(Team { members });
|
|
self.current_team_idx = Some(self.event.teams.len() - 1);
|
|
self
|
|
}
|
|
|
|
/// Add a team from fully-specified [`Member`] values.
|
|
///
|
|
/// [`EventBuilder::team`] is the common case and builds members with
|
|
/// `Member::new`, which leaves `prior` and `drift_scale` unset. This is the
|
|
/// escape hatch for when they matter:
|
|
///
|
|
/// ```
|
|
/// # use trueskill_tt::{Gaussian, History, Member};
|
|
/// # let mut h = History::builder().build();
|
|
/// h.event(0)
|
|
/// .team(["player"])
|
|
/// .members([Member::new("layout_7")
|
|
/// .with_drift_scale(0.0)
|
|
/// .with_prior(Gaussian::from_ms(0.0, 1.0))])
|
|
/// .ranking([0, 1])
|
|
/// .commit()?;
|
|
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
|
/// ```
|
|
///
|
|
/// One method rather than a `priors` and a `drift_scales` setter beside
|
|
/// `weights`: those would have to grow a parallel array — and a parallel
|
|
/// length check — every time `Member` gains a field, and each one would be
|
|
/// a new way to get the lengths wrong. `Member`'s own builder already
|
|
/// expresses all of it.
|
|
///
|
|
/// `prior` and `drift_scale` are competitor configuration rather than
|
|
/// per-event values; see [`Member`] for what that means for a key the
|
|
/// history already knows.
|
|
pub fn members<I: IntoIterator<Item = Member<K>>>(mut self, members: I) -> Self {
|
|
self.event.teams.push(Team::with_members(members));
|
|
self.current_team_idx = Some(self.event.teams.len() - 1);
|
|
self
|
|
}
|
|
|
|
/// Set per-member weights for the most recently added team.
|
|
///
|
|
/// A length mismatch is recorded and returned by [`EventBuilder::commit`]
|
|
/// as `InferenceError::MismatchedShape`, in both debug and release. The
|
|
/// weights are not applied in that case, so a partially-weighted team
|
|
/// cannot reach the history.
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// Panics if called before any `.team(...)`.
|
|
pub fn weights<I: IntoIterator<Item = f64>>(mut self, weights: I) -> Self {
|
|
let idx = self
|
|
.current_team_idx
|
|
.expect(".weights(...) called before any .team(...)");
|
|
|
|
let ws: Vec<f64> = weights.into_iter().collect();
|
|
let team = &mut self.event.teams[idx];
|
|
|
|
if ws.len() != team.members.len() {
|
|
self.error.get_or_insert(InferenceError::MismatchedShape {
|
|
kind: "weights",
|
|
expected: team.members.len(),
|
|
got: ws.len(),
|
|
});
|
|
|
|
return self;
|
|
}
|
|
|
|
for (m, w) in team.members.iter_mut().zip(ws) {
|
|
m.weight = w;
|
|
}
|
|
|
|
self
|
|
}
|
|
|
|
/// Set explicit ranks per team (length must equal number of teams).
|
|
pub fn ranking<I: IntoIterator<Item = u32>>(mut self, ranks: I) -> Self {
|
|
self.event.outcome = Outcome::ranking(ranks);
|
|
self
|
|
}
|
|
|
|
/// Set explicit per-team continuous scores; higher = better.
|
|
pub fn scores<I: IntoIterator<Item = f64>>(mut self, scores: I) -> Self {
|
|
self.event.outcome = crate::Outcome::scores(scores);
|
|
self
|
|
}
|
|
|
|
/// Set explicit per-team continuous scores with a per-event noise override.
|
|
///
|
|
/// `sigma` overrides `HistoryBuilder::score_sigma` for this event only.
|
|
/// Must be `> 0.0`. Constructing the outcome with a non-positive or NaN
|
|
/// sigma is allowed; the value is rejected with
|
|
/// `InferenceError::InvalidParameter` when the event is ingested, so
|
|
/// callers get an error from `commit` rather than a panic.
|
|
pub fn scores_with_sigma<I: IntoIterator<Item = f64>>(mut self, scores: I, sigma: f64) -> Self {
|
|
self.event.outcome = crate::Outcome::scores_with_sigma(scores, sigma);
|
|
self
|
|
}
|
|
|
|
/// Mark team `winner_idx` as winner; others tied for last.
|
|
pub fn winner(mut self, winner_idx: u32) -> Self {
|
|
self.event.outcome = Outcome::winner(winner_idx, self.event.teams.len() as u32);
|
|
self
|
|
}
|
|
|
|
/// All teams tied.
|
|
pub fn draw(mut self) -> Self {
|
|
self.event.outcome = Outcome::draw(self.event.teams.len() as u32);
|
|
self
|
|
}
|
|
|
|
/// Commit the event to the history.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns the first validation failure recorded while building — see
|
|
/// [`EventBuilder::weights`] — otherwise forwards to
|
|
/// [`History::add_events`] and returns its errors.
|
|
pub fn commit(self) -> Result<(), InferenceError> {
|
|
if let Some(error) = self.error {
|
|
return Err(error);
|
|
}
|
|
|
|
self.history.add_events(std::iter::once(self.event))
|
|
}
|
|
}
|