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
238 lines
8.8 KiB
Rust
238 lines
8.8 KiB
Rust
//! Typed event description for bulk ingestion.
|
||
//!
|
||
//! `Event<T, K>` is the public event shape taken by `History::add_events`. It
|
||
//! is a typed front end, not a replacement: `add_events` flattens it into the
|
||
//! nested `Vec<Vec<Vec<Index>>>` / `Vec<Vec<f64>>` / `Vec<Vec<Vec<f64>>>` that
|
||
//! the internal `add_events_with_prior` chokepoint still takes, and which
|
||
//! `record_winner` and `record_draw` also route through.
|
||
|
||
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, PartialEq)]
|
||
pub struct Event<T: Time, K> {
|
||
/// When the match happened, on the history's time axis.
|
||
///
|
||
/// Events sharing a `time` land in the same time slice and are fitted
|
||
/// together, so nothing distinguishes their order. Drift is driven by the
|
||
/// gap between a competitor's *consecutive appearances*, not by the gap
|
||
/// between slices, so a competitor idle across several slices accumulates
|
||
/// the whole span at once when it next plays.
|
||
pub time: T,
|
||
/// The teams that took part, positionally aligned with `outcome`: team `i`
|
||
/// here is the team `outcome` ranks or scores at index `i`.
|
||
///
|
||
/// Ingestion rejects fewer than two teams (`NotEnoughTeams`) and any team
|
||
/// with no members (`EmptyTeam`).
|
||
pub teams: SmallVec<[Team<K>; 4]>,
|
||
/// How the match ended: ranks (lower is better) or per-team scores (higher
|
||
/// is better), one entry per entry of `teams`.
|
||
///
|
||
/// A tie — two equal ranks — needs a positive `p_draw`, otherwise
|
||
/// ingestion fails with `TieWithoutDrawProbability`.
|
||
pub outcome: Outcome,
|
||
}
|
||
|
||
/// A team: list of members competing together.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
#[must_use]
|
||
pub struct Team<K> {
|
||
/// The competitors playing together, in no significant order: the team's
|
||
/// performance is the weight-scaled sum over its members, which does not
|
||
/// depend on how they are listed.
|
||
///
|
||
/// Must be non-empty — an empty team contributes no performance at all, so
|
||
/// ingestion rejects it with `EmptyTeam` rather than returning a plausible
|
||
/// posterior for whoever it was matched against.
|
||
pub members: SmallVec<[Member<K>; 4]>,
|
||
}
|
||
|
||
impl<K> Team<K> {
|
||
/// A team with no members yet, to be filled through the public `members`
|
||
/// field.
|
||
///
|
||
/// Committing it while still empty is an `EmptyTeam` error.
|
||
pub fn new() -> Self {
|
||
Self {
|
||
members: SmallVec::new(),
|
||
}
|
||
}
|
||
|
||
/// A team of exactly these competitors.
|
||
///
|
||
/// Members must be built already — `Member::from(key)` covers the common
|
||
/// case of a plain key at default weight with no overrides.
|
||
pub fn with_members<I: IntoIterator<Item = Member<K>>>(members: I) -> Self {
|
||
Self {
|
||
members: members.into_iter().collect(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl<K> Default for Team<K> {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
/// One member of a team, identified by user key `K`.
|
||
///
|
||
/// `weight` applies per event and defaults to 1.0.
|
||
///
|
||
/// `prior` and `drift_scale` are **competitor configuration**, not per-event
|
||
/// values. Setting either applies to the competitor for the whole history, not
|
||
/// just to this event, and applies whenever it is supplied — including on a key
|
||
/// the history already knows. Because configuration lives on the competitor and
|
||
/// `converge` refits from competitor state, configuring one late still refits
|
||
/// the whole history rather than taking effect only from that event onward.
|
||
///
|
||
/// Repeating the same value is inert, which is the expected shape when the
|
||
/// configuration is a property of the domain. Supplying two *different* values
|
||
/// 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, PartialEq)]
|
||
#[must_use]
|
||
pub struct Member<K> {
|
||
/// The competitor's identity. Equal keys across events are the same
|
||
/// competitor: `History` interns each distinct key to an internal `Index`
|
||
/// the first time it sees it, and every later appearance resolves to that
|
||
/// same competitor's temporal state.
|
||
pub key: K,
|
||
/// This member's share of the team's performance, for this event only.
|
||
///
|
||
/// The team's performance is the sum of `weight × member performance`, so
|
||
/// `1.0` is a full share and `0.5` counts the member half; the message
|
||
/// coming back to the member is divided by the same weight. Defaults to
|
||
/// `1.0`.
|
||
///
|
||
/// Must be finite — a NaN or infinite weight is `InvalidParameter` at
|
||
/// ingestion. Zero and negative are accepted, both being expressible in
|
||
/// the same arithmetic.
|
||
pub weight: f64,
|
||
/// Starting skill for this competitor, replacing the history's `mu`/`sigma`
|
||
/// default. `None` keeps the history default.
|
||
///
|
||
/// Competitor configuration, not a per-event value; see the type docs.
|
||
pub prior: Option<Gaussian>,
|
||
/// Multiplier on the drift *variance* this competitor accumulates.
|
||
/// `None` means 1.0.
|
||
pub drift_scale: Option<f64>,
|
||
}
|
||
|
||
impl<K> Member<K> {
|
||
/// A competitor taking a full share of its team's performance, with no
|
||
/// configuration overrides: the history's prior and drift apply.
|
||
pub fn new(key: K) -> Self {
|
||
Self {
|
||
key,
|
||
weight: 1.0,
|
||
prior: None,
|
||
drift_scale: None,
|
||
}
|
||
}
|
||
|
||
/// Change how much of the team's performance this member accounts for.
|
||
///
|
||
/// Unlike `prior` and `drift_scale`, this is genuinely per-event: the same
|
||
/// key can carry a different weight in every event it appears in, which is
|
||
/// what makes it usable for partial participation — a substitute who
|
||
/// played half the match, a doubles partner credited unequally.
|
||
pub fn with_weight(mut self, weight: f64) -> Self {
|
||
self.weight = weight;
|
||
self
|
||
}
|
||
|
||
/// Set this competitor's starting skill estimate.
|
||
///
|
||
/// Competitor configuration, not a per-event value: it applies for the
|
||
/// whole history and applies whenever it is supplied, including on a key
|
||
/// the history already knows. See the type docs.
|
||
pub fn with_prior(mut self, prior: Gaussian) -> Self {
|
||
self.prior = Some(prior);
|
||
self
|
||
}
|
||
|
||
/// Scale how fast this competitor drifts, relative to the history's drift.
|
||
///
|
||
/// The scale multiplies the drift *variance*, so it is in the same units as
|
||
/// `gamma`: `ConstantDrift::new(g)` at `scale = s` behaves exactly as
|
||
/// `ConstantDrift::new(g * s)` would for this competitor alone.
|
||
///
|
||
/// `0.0` pins the competitor still — useful for a reference point that
|
||
/// shares a scale with moving competitors but should not itself move: a bot
|
||
/// at a known strength, a rating floor, a course difficulty.
|
||
///
|
||
/// Applies for the whole history and whenever it is supplied, including on
|
||
/// a key the history already knows; see the type docs.
|
||
/// Must be finite and non-negative, or ingestion fails with
|
||
/// [`InferenceError::InvalidParameter`](crate::InferenceError::InvalidParameter).
|
||
pub fn with_drift_scale(mut self, scale: f64) -> Self {
|
||
self.drift_scale = Some(scale);
|
||
self
|
||
}
|
||
}
|
||
|
||
/// Convenience: a member is a user key with default weight 1.0 and no prior.
|
||
impl<K> From<K> for Member<K> {
|
||
fn from(key: K) -> Self {
|
||
Self::new(key)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::Outcome;
|
||
|
||
#[test]
|
||
fn member_new_has_unit_weight_no_prior() {
|
||
let m = Member::new("alice");
|
||
assert_eq!(m.key, "alice");
|
||
assert_eq!(m.weight, 1.0);
|
||
assert!(m.prior.is_none());
|
||
assert!(m.drift_scale.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn member_builder_methods_chain() {
|
||
let m = Member::new("alice")
|
||
.with_weight(0.5)
|
||
.with_prior(Gaussian::from_ms(20.0, 5.0))
|
||
.with_drift_scale(0.0);
|
||
assert_eq!(m.weight, 0.5);
|
||
assert!(m.prior.is_some());
|
||
assert_eq!(m.drift_scale, Some(0.0));
|
||
}
|
||
|
||
#[test]
|
||
fn member_from_key() {
|
||
let m: Member<&str> = "bob".into();
|
||
assert_eq!(m.key, "bob");
|
||
assert_eq!(m.weight, 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn team_with_members_collects() {
|
||
let t: Team<&str> = Team::with_members([Member::new("a"), Member::new("b")]);
|
||
assert_eq!(t.members.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn event_construction() {
|
||
use smallvec::smallvec;
|
||
let e: Event<i64, &str> = Event {
|
||
time: 1,
|
||
teams: smallvec![
|
||
Team::with_members([Member::new("a")]),
|
||
Team::with_members([Member::new("b")]),
|
||
],
|
||
outcome: Outcome::winner(0, 2),
|
||
};
|
||
assert_eq!(e.teams.len(), 2);
|
||
assert_eq!(e.time, 1);
|
||
}
|
||
}
|