//! Typed event description for bulk ingestion. //! //! `Event` 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>>` 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 { /// 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; 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 { /// 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; 4]>, } impl Team { /// 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>>(members: I) -> Self { Self { members: members.into_iter().collect(), } } } impl Default for Team { 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 { /// 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, /// Multiplier on the drift *variance* this competitor accumulates. /// `None` means 1.0. pub drift_scale: Option, } impl Member { /// 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 From for Member { 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 = 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); } }