A one-team event reached `run_chain`, which builds one diff link per
adjacent pair of teams, leaving it to index `links[1..]` on an empty
vector. That panicked with "range start index 1 out of range for slice
of length 0" — from `History::add_events`, in a release build, through
entirely safe API.
An empty team was the quieter half of the same gap. It contributes no
performance, so a malformed event converged and handed back a finite,
plausible-looking posterior for whoever it was matched against. That is
this crate's characteristic defect: a public surface reporting a
constant that looks like an answer.
A non-finite score was the third. `converge` did report NonFiniteResult,
so it was detected — but a caller reading `current_skill` before
converging was handed `tau: NaN` with nothing to say so.
`NotEnoughTeams` and `EmptyTeam` already existed. They were checked on
the prediction paths and nowhere else, which is exactly why ingestion
could still manufacture the states they describe. The checks go in
`add_events_with_prior` alongside the tie check, for the same reason
that one is there: every ingestion route lands on it, so `record_winner`,
`record_draw` and `EventBuilder` inherit them rather than each needing
their own.
Also corrects documentation that had been stating the opposite of the
code since 8c087ad in 0.4.0. README.md and the `with_prior` /
`with_drift_scale` doc comments all still said competitor configuration
was "captured at first appearance" and had "no effect" on a known key.
It now applies whenever supplied and refits the whole history. A reader
would have concluded late configuration was impossible and built a
workaround for a limitation that does not exist. CI compiles README code
blocks but not prose, which is why it survived three releases.
The comment in tests/degenerate_inputs.rs claiming a one-team event was
"rejected for an unrelated reason" was wrong when written — it panicked.
Refs #18, #26
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
179 lines
5.5 KiB
Rust
179 lines
5.5 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)]
|
|
pub struct Event<T: Time, K> {
|
|
pub time: T,
|
|
pub teams: SmallVec<[Team<K>; 4]>,
|
|
pub outcome: Outcome,
|
|
}
|
|
|
|
/// A team: list of members competing together.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Team<K> {
|
|
pub members: SmallVec<[Member<K>; 4]>,
|
|
}
|
|
|
|
impl<K> Team<K> {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
members: SmallVec::new(),
|
|
}
|
|
}
|
|
|
|
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)]
|
|
pub struct Member<K> {
|
|
pub key: K,
|
|
pub weight: f64,
|
|
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> {
|
|
pub fn new(key: K) -> Self {
|
|
Self {
|
|
key,
|
|
weight: 1.0,
|
|
prior: None,
|
|
drift_scale: None,
|
|
}
|
|
}
|
|
|
|
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(g)` at `scale = s` behaves exactly as
|
|
/// `ConstantDrift(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);
|
|
}
|
|
}
|