From c4194b00513e897f15883b3c96bcd1b5c52f8e39 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 10 Sep 2026 07:17:12 +0200 Subject: [PATCH] feat: HistoryBuilder::default_rating_for, a rule instead of a roll call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `register` states configuration for one competitor, which needs the key set up front. A consumer ingesting an event stream generally does not have it — and "every layout is static" is a rule, not a list. This makes it one statement that cannot be forgotten on an ingestion path. History::builder() .default_rating_for(|key: &&str| { key.starts_with("layout_") .then(|| StartingPoint::new().drift_scale(0.0)) }) .build() A fifth type parameter, defaulted to `NoRule`, so it costs a caller who does not use one exactly nothing: `History` still spells out. Two deviations from #53, both because implementing it exposed something the issue could not have known. **A trait, not a bare `Fn` bound.** #53's option 1 was a raw `R: Fn(&K) -> Option>`. A closure's type cannot be written down, and the motivating consumer holds its `History` in application state — so it has to name the type in a struct field, and option 1 makes that impossible. `RatingRule` is implementable on a named type; `tests/rating_rule.rs` has the struct-field case that would not have compiled otherwise. `default_rating_for` still takes a closure for the common case, via `FnRule`. **The rule returns a `StartingPoint`, not a `Rating`.** A `Rating` also carries `beta` and the drift model, which describe the *history* rather than one competitor — a rule that could vary them would be describing a different model per competitor. What the create branch actually applies is the prior and the drift scale, the same pair a `Member` may carry, so that is what the rule supplies. It also keeps `RatingRule` free of `T` and `D`: with `Rating` in the signature, `drift` and `time_type` stop compiling after a rule is set, because `R: RatingRule` does not imply `R: RatingRule`. **Precedence, which #53 left open: explicit beats the rule, field by field.** The alternative — `ConflictingCompetitorConfig` — would make a single exceptional competitor incompatible with having any rule at all. Two *explicit* declarations that disagree stay an error, because neither is more specific than the other, and a test pins that they still do. `key_type` resets the rule to `NoRule`: a `RatingRule` cannot answer questions about `K2`. Every test carries a control, and one of them corrected me. I first asserted that a non-matching competitor's *posterior* was untouched. It is not, and should not be: alice plays the pinned layout, and what she learns from beating it depends on how sure the model is about it. The control is her configuration. Closes #53. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ --- src/event_builder.rs | 10 ++- src/history.rs | 149 +++++++++++++++++++++++++++------ src/lib.rs | 2 + src/rating_rule.rs | 140 +++++++++++++++++++++++++++++++ tests/rating_rule.rs | 192 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 466 insertions(+), 27 deletions(-) create mode 100644 src/rating_rule.rs create mode 100644 tests/rating_rule.rs diff --git a/src/event_builder.rs b/src/event_builder.rs index abad038..561a350 100644 --- a/src/event_builder.rs +++ b/src/event_builder.rs @@ -38,14 +38,15 @@ use crate::{ /// ``` #[must_use = "an event is only recorded by `.commit()`; a dropped builder \ silently ingests nothing"] -pub struct EventBuilder<'h, T, D, O, K> +pub struct EventBuilder<'h, T, D, O, K, R> where T: Time, D: Drift, O: Observer, K: Eq + std::hash::Hash + Clone, + R: crate::RatingRule, { - history: &'h mut History, + history: &'h mut History, event: Event, current_team_idx: Option, /// First validation failure seen while building, surfaced by `commit`. @@ -58,14 +59,15 @@ where error: Option, } -impl<'h, T, D, O, K> EventBuilder<'h, T, D, O, K> +impl<'h, T, D, O, K, R> EventBuilder<'h, T, D, O, K, R> where T: Time, D: Drift, O: Observer, K: Eq + std::hash::Hash + Clone, + R: crate::RatingRule, { - pub(crate) fn new(history: &'h mut History, time: T) -> Self { + pub(crate) fn new(history: &'h mut History, time: T) -> Self { Self { history, event: Event { diff --git a/src/history.rs b/src/history.rs index e243b10..b2010a8 100644 --- a/src/history.rs +++ b/src/history.rs @@ -17,6 +17,7 @@ use crate::{ observer::{NullObserver, Observer}, predict::Prediction, rating::Rating, + rating_rule::{FnRule, NoRule, RatingRule, StartingPoint}, sort_time, storage::CompetitorStore, time::Time, @@ -32,7 +33,7 @@ use crate::{ /// an unknown key. None of them can be changed after `build`, because they /// define the model the fit is of. /// -/// Parameterised as [`History`] is, `HistoryBuilder`, with the +/// Parameterised as [`History`] is, `HistoryBuilder`, with the /// same defaults. /// /// Two of the setters change the builder's *type* rather than a field — @@ -47,6 +48,7 @@ pub struct HistoryBuilder< T: Time = i64, D: Drift = ConstantDrift, O: Observer = NullObserver, + R: RatingRule = NoRule, > { mu: f64, sigma: f64, @@ -57,11 +59,14 @@ pub struct HistoryBuilder< convergence: ConvergenceOptions, observer: O, unknown_keys: crate::UnknownKeys, + rule: R, _time: PhantomData, _key: PhantomData, } -impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder { +impl, O: Observer, K: Eq + Hash + Clone, R: RatingRule> + HistoryBuilder +{ /// Prior mean skill. /// /// # Panics @@ -153,9 +158,10 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< /// implementation. `converge` checks the variance each competitor actually /// accumulates and reports `InvalidParameter` if it is negative or /// non-finite. - pub fn drift>(self, drift: D2) -> HistoryBuilder { + pub fn drift>(self, drift: D2) -> HistoryBuilder { HistoryBuilder { drift, + rule: self.rule, mu: self.mu, sigma: self.sigma, beta: self.beta, @@ -252,13 +258,14 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< /// assert!(h.current_skill(&"alice").unwrap().mu() > 0.0); /// # Ok::<(), trueskill_tt::InferenceError>(()) /// ``` - pub fn time_type(self) -> HistoryBuilder + pub fn time_type(self) -> HistoryBuilder where T2: Time, D: Drift, O: Observer, { HistoryBuilder { + rule: self.rule, mu: self.mu, sigma: self.sigma, beta: self.beta, @@ -286,8 +293,79 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< /// h.record_winner(&"alice".to_string(), &"bob".to_string(), 1)?; /// # Ok::<(), trueskill_tt::InferenceError>(()) /// ``` - pub fn key_type(self) -> HistoryBuilder { + pub fn key_type(self) -> HistoryBuilder { HistoryBuilder { + // A `RatingRule` cannot answer questions about `K2`, so + // changing the key type drops it. Set the key type first. + rule: NoRule, + mu: self.mu, + sigma: self.sigma, + beta: self.beta, + drift: self.drift, + p_draw: self.p_draw, + score_sigma: self.score_sigma, + convergence: self.convergence, + observer: self.observer, + unknown_keys: self.unknown_keys, + _time: PhantomData, + _key: PhantomData, + } + } + + /// Supply a rule that configures competitors the history has not seen. + /// + /// `register` states configuration for one competitor at a time, which + /// needs the key set up front. This states it for a *class* — "every + /// layout is static" — as one statement that cannot be forgotten on an + /// ingestion path. + /// + /// Consulted once per competitor, at creation. Explicit configuration from + /// `register` or a [`Member`](crate::Member) overrides it field by field; + /// see [`RatingRule`] for why the specific beats the general here while + /// two explicit declarations that disagree stay an error. + /// + /// Changes the builder's type — bind the result — and must come *after* + /// [`key_type`](HistoryBuilder::key_type), since a `RatingRule` cannot + /// answer questions about a different key type. + /// + /// ``` + /// # use trueskill_tt::{Gaussian, History, StartingPoint}; + /// let h = History::builder() + /// .default_rating_for(|key: &&'static str| { + /// key.starts_with("bot_") + /// .then(|| StartingPoint::new().drift_scale(0.0)) + /// }) + /// .build(); + /// # let _ = h; + /// ``` + pub fn default_rating_for(self, rule: F) -> HistoryBuilder> + where + F: Fn(&K) -> Option, + { + HistoryBuilder { + rule: FnRule(rule), + mu: self.mu, + sigma: self.sigma, + beta: self.beta, + drift: self.drift, + p_draw: self.p_draw, + score_sigma: self.score_sigma, + convergence: self.convergence, + observer: self.observer, + unknown_keys: self.unknown_keys, + _time: PhantomData, + _key: PhantomData, + } + } + + /// Supply a rule as a named type implementing [`RatingRule`]. + /// + /// The counterpart of [`default_rating_for`](HistoryBuilder::default_rating_for) + /// for when the resulting `History<..>` has to be written down in a struct + /// field, which a closure's type makes impossible. + pub fn rating_rule>(self, rule: R2) -> HistoryBuilder { + HistoryBuilder { + rule, mu: self.mu, sigma: self.sigma, beta: self.beta, @@ -308,8 +386,9 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< /// observer by value; to keep a handle on one that accumulates state, pass /// an `Arc` and keep a clone, or read it back with /// [`History::observer`] / [`History::into_observer`]. - pub fn observer>(self, observer: O2) -> HistoryBuilder { + pub fn observer>(self, observer: O2) -> HistoryBuilder { HistoryBuilder { + rule: self.rule, mu: self.mu, sigma: self.sigma, beta: self.beta, @@ -327,8 +406,9 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< /// Finish configuring and produce an empty [`History`]. /// /// Every parameter was validated as it was set, so this cannot fail. - pub fn build(self) -> History { + pub fn build(self) -> History { History { + rule: self.rule, size: 0, time_slices: Vec::new(), competitors: CompetitorStore::new(), @@ -357,6 +437,7 @@ impl, O: Observer, K: Eq + Hash + Clone> HistoryBuilder< impl Default for HistoryBuilder { fn default() -> Self { Self { + rule: NoRule, mu: MU, sigma: SIGMA, beta: BETA, @@ -451,7 +532,7 @@ impl CompetitorConfig { /// /// # Type parameters /// -/// `History` — key type, time type, drift model, observer — all +/// `History` — key type, time type, drift model, observer — all /// defaulted, so `History` alone means `&'static str` keys on an `i64` time /// axis with [`ConstantDrift`] and no observer, and `History` is the /// whole spelling for owned keys. @@ -465,6 +546,7 @@ pub struct History< T: Time = i64, D: Drift = ConstantDrift, O: Observer = NullObserver, + R: RatingRule = NoRule, > { size: usize, pub(crate) time_slices: Vec>, @@ -478,6 +560,8 @@ pub struct History< score_sigma: f64, convergence: ConvergenceOptions, observer: O, + /// Supplies a starting point for competitors nobody declared explicitly. + rule: R, unknown_keys: crate::UnknownKeys, /// Competitor configuration explicitly declared so far, by whichever route. /// @@ -552,7 +636,9 @@ impl HistoryBuilder, O: Observer, K: Eq + Hash + Clone> History { +impl, O: Observer, K: Eq + Hash + Clone, R: RatingRule> + History +{ /// Promote a key to its [`Index`], creating the entry if it is new. /// /// Crate-internal since #73: interning reserves a storage slot and nothing @@ -567,7 +653,9 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History { +impl, O: Observer, K: Eq + Hash + Clone, R: RatingRule> + History +{ fn iteration(&mut self) -> (f64, f64) { let mut step = (0.0, 0.0); @@ -791,10 +879,13 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History Result, InferenceError> { + pub fn joint(&self) -> Result, InferenceError> { if self.time_slices.is_empty() { return Err(InferenceError::JointUnavailable { reason: "the history has no events", @@ -2088,7 +2179,9 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History { +impl, O: Observer, K: Eq + Hash + Clone, R: RatingRule> + History +{ pub(crate) fn add_events_with_prior( &mut self, mut composition: Vec>>, @@ -2325,10 +2418,17 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History crate::event_builder::EventBuilder<'_, T, D, O, K> { + pub fn event(&mut self, time: T) -> crate::event_builder::EventBuilder<'_, T, D, O, K, R> { crate::event_builder::EventBuilder::new(self, time) } @@ -2693,8 +2793,8 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> std::fmt::Debug - for History +impl, O: Observer, K: Eq + Hash + Clone, R: RatingRule> std::fmt::Debug + for History { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("History") @@ -2758,8 +2858,9 @@ pub struct Joint< T: Time = i64, D: Drift = ConstantDrift, O: Observer = NullObserver, + R: RatingRule = NoRule, > { - history: &'h History, + history: &'h History, cholesky: crate::joint::Cholesky, /// `(row, slice)` of each competitor's latest appearance. latest: HashMap, @@ -2771,8 +2872,8 @@ pub struct Joint< /// Deliberately does not print the factorisation, which is `n^2` floats and /// would make a `{:?}` of a large joint unreadable and slow. -impl, O: Observer, K: Eq + Hash + Clone> std::fmt::Debug - for Joint<'_, K, T, D, O> +impl, O: Observer, K: Eq + Hash + Clone, R: RatingRule> std::fmt::Debug + for Joint<'_, K, T, D, O, R> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Joint") @@ -2781,7 +2882,9 @@ impl, O: Observer, K: Eq + Hash + Clone> std::fmt::Debug } } -impl, O: Observer> Joint<'_, K, T, D, O> { +impl, O: Observer, R: RatingRule> + Joint<'_, K, T, D, O, R> +{ /// Number of variables in the joint: the history's appearances, after /// collapsing consecutive pairs a competitor does not drift between. /// diff --git a/src/lib.rs b/src/lib.rs index 360f9cd..48a4a1b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -144,6 +144,7 @@ mod outcome; mod predict; pub(crate) mod quadrature; mod rating; +pub mod rating_rule; pub(crate) mod storage; mod time; mod time_slice; @@ -162,6 +163,7 @@ pub use observer::{NullObserver, Observer}; pub use outcome::Outcome; pub use predict::Prediction; pub use rating::Rating; +pub use rating_rule::{FnRule, NoRule, RatingRule, StartingPoint}; /// The `smallvec` crate, re-exported. /// /// Four public items name `SmallVec` in their signatures: [`Event::teams`], diff --git a/src/rating_rule.rs b/src/rating_rule.rs new file mode 100644 index 0000000..a21588d --- /dev/null +++ b/src/rating_rule.rs @@ -0,0 +1,140 @@ +//! Declarative competitor configuration: a rule that supplies defaults for +//! competitors the history has not seen yet. +//! +//! [`History::register`](crate::History::register) states configuration for +//! *one* competitor, which covers a bot at a known strength or a handful of +//! reference points. It does not cover a *rule* — "every layout is static" — +//! because enumerating the keys means knowing the full key set up front, which +//! a consumer ingesting an event stream generally does not. +//! +//! ``` +//! use trueskill_tt::{Gaussian, History, StartingPoint}; +//! +//! let mut h = History::builder() +//! // Layouts do not improve; everybody else does. +//! .default_rating_for(|key: &&'static str| { +//! key.starts_with("layout_") +//! .then(|| StartingPoint::new().prior(Gaussian::from_ms(0.0, 1.0)).drift_scale(0.0)) +//! }) +//! .build(); +//! +//! h.event(1).team(["layout_7"]).team(["alice"]).scores([3.0, 1.0]).commit()?; +//! h.converge()?; +//! +//! // The layout was pinned, so its uncertainty barely moved. +//! assert!(h.current_skill("layout_7").unwrap().sigma() < 1.0); +//! # Ok::<(), trueskill_tt::InferenceError>(()) +//! ``` +//! +//! # Why a trait, and why a fifth type parameter +//! +//! The rule is a type parameter on [`History`](crate::History), defaulted to +//! [`NoRule`], so it costs a caller who does not use one exactly nothing — +//! `History` still spells out in full. A boxed `dyn Fn` would have +//! avoided the parameter at the price of `HistoryBuilder`'s derived `Clone` +//! and `Debug`. +//! +//! It is a trait rather than a bare `Fn` bound because a closure's type cannot +//! be written down, and the motivating consumer holds its `History` in +//! application state — so it has to name the type in a struct field. Implement +//! [`RatingRule`] on a named type of your own and that field is spellable. +//! +//! # What a rule may set, and what it may not +//! +//! A [`StartingPoint`], which is the same pair a +//! [`Member`](crate::Member) may carry: the prior and the drift scale. Not +//! `beta` and not the drift model — those describe the *history*, not one +//! competitor, and a rule that could vary them would be describing a different +//! model per competitor rather than a starting point within one. +//! +//! Keeping the rule to those two also keeps it independent of the history's +//! time and drift types, so [`HistoryBuilder::drift`](crate::HistoryBuilder::drift) +//! and [`HistoryBuilder::time_type`](crate::HistoryBuilder::time_type) still +//! work after a rule is set. + +use crate::gaussian::Gaussian; + +/// What a [`RatingRule`] may say about a competitor. +/// +/// Both fields are optional and are applied independently, so a rule that sets +/// only `drift_scale` does not also assert a prior — the same reason +/// `Member`'s configuration is carried as "what was explicitly set" rather +/// than as a merged `Rating`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[must_use] +pub struct StartingPoint { + pub(crate) prior: Option, + pub(crate) drift_scale: Option, +} + +impl StartingPoint { + /// A starting point that says nothing yet. + pub fn new() -> Self { + Self::default() + } + + /// Start this competitor from `prior` instead of the history's + /// `mu`/`sigma`. + pub fn prior(mut self, prior: Gaussian) -> Self { + self.prior = Some(prior); + self + } + + /// Scale how fast this competitor drifts, relative to the history's drift + /// model. `0.0` pins them still. + pub fn drift_scale(mut self, drift_scale: f64) -> Self { + self.drift_scale = Some(drift_scale); + self + } +} + +/// Supplies a [`StartingPoint`] for competitors the history has not seen. +/// +/// Consulted once per competitor, when that competitor is created — not per +/// event and not per sweep. Returning `None` means "no opinion": the +/// competitor takes the history's own defaults. +/// +/// # Precedence +/// +/// Explicit configuration wins, field by field. A `prior` or `drift_scale` +/// from [`History::register`](crate::History::register) or from a +/// [`Member`](crate::Member) overrides whatever the rule returned for that +/// competitor. The specific beats the general, which is the only reading that +/// lets a rule have exceptions — treating the disagreement as +/// `ConflictingCompetitorConfig` would make one exceptional competitor +/// incompatible with having any rule at all. +/// +/// Two *explicit* declarations that disagree remain an error. Neither of those +/// is more specific than the other, so there is nothing to prefer. +pub trait RatingRule { + /// Where this competitor should start, or `None` for the history's + /// defaults. + fn starting_point(&self, key: &K) -> Option; +} + +/// The default rule: no opinion about anybody. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct NoRule; + +impl RatingRule for NoRule { + #[inline] + fn starting_point(&self, _key: &K) -> Option { + None + } +} + +/// A [`RatingRule`] built from a closure by +/// [`HistoryBuilder::default_rating_for`](crate::HistoryBuilder::default_rating_for). +/// +/// Public so it can be named where a closure's own type cannot be, though +/// implementing [`RatingRule`] on a named type of your own is the better way +/// to get a `History<..>` you can write down in a struct field. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FnRule(pub F); + +impl Option> RatingRule for FnRule { + #[inline] + fn starting_point(&self, key: &K) -> Option { + (self.0)(key) + } +} diff --git a/tests/rating_rule.rs b/tests/rating_rule.rs new file mode 100644 index 0000000..d0048a5 --- /dev/null +++ b/tests/rating_rule.rs @@ -0,0 +1,192 @@ +//! `HistoryBuilder::default_rating_for`: configuring a *class* of competitors +//! rather than one at a time (#53). +//! +//! Every test carries a control — a key the rule does not match — so none can +//! pass by the rule firing for everybody, which would be indistinguishable +//! from changing the history defaults. + +use trueskill_tt::{ + ConstantDrift, Gaussian, History, HistoryBuilder, InferenceError, Member, NullObserver, + RatingRule, StartingPoint, +}; + +/// Pinned: no drift, and a tight prior at a known strength. +fn pinned() -> StartingPoint { + StartingPoint::new() + .prior(Gaussian::from_ms(5.0, 0.5)) + .drift_scale(0.0) +} + +fn play>( + h: &mut History<&'static str, i64, ConstantDrift, NullObserver, R>, +) { + for t in 1..=6 { + h.event(t) + .team(["layout_a"]) + .team(["alice"]) + .scores([3.0, 1.0]) + .commit() + .expect("ingests"); + } + h.converge().expect("converges"); +} + +#[test] +fn a_rule_configures_every_matching_key_without_naming_them() { + let mut ruled = History::builder() + .gamma(0.5) + .default_rating_for(|key: &&'static str| key.starts_with("layout_").then(pinned)) + .build(); + play(&mut ruled); + + let mut plain = History::builder().gamma(0.5).build(); + play(&mut plain); + + let layout = ruled.current_skill("layout_a").expect("played"); + + // The rule pinned the layout: tight prior, no drift. + assert!( + layout.sigma() < 0.5, + "the layout should stay near its pinned prior, got sigma {}", + layout.sigma() + ); + assert_ne!( + layout.sigma(), + plain.current_skill("layout_a").unwrap().sigma(), + "the rule must actually change the fit" + ); + + // The control is the *configuration*, not the posterior. Alice's posterior + // legitimately moves — she is playing a differently-configured opponent, + // and what she learns from beating it depends on how sure the model is + // about it. What must not move is what the rule was asked about. + let alice = ruled.rating("alice").expect("played"); + assert_eq!( + alice.drift_scale(), + 1.0, + "a non-matching key keeps the default drift" + ); + assert_eq!( + (alice.prior().mu(), alice.prior().sigma()), + { + let p = plain.rating("alice").expect("played").prior(); + (p.mu(), p.sigma()) + }, + "a non-matching key keeps the history's prior" + ); +} + +#[test] +fn a_rule_fires_for_a_competitor_first_seen_through_record_winner() { + // `record_winner` cannot carry configuration, which is the case a rule + // exists for. + let mut h = History::builder() + .default_rating_for(|key: &&'static str| key.starts_with("bot_").then(pinned)) + .build(); + h.record_winner(&"bot_1", &"human", 1).expect("ingests"); + h.converge().expect("converges"); + + assert_eq!(h.rating("bot_1").expect("known").drift_scale(), 0.0); + assert_eq!(h.rating("human").expect("known").drift_scale(), 1.0); +} + +#[test] +fn explicit_configuration_overrides_a_rule_field_by_field() { + let mut h = History::builder() + .default_rating_for(|_: &&'static str| Some(pinned())) + .build(); + + // Sets only the prior, so the rule's `drift_scale` must survive. + h.register(Member::new("a").with_prior(Gaussian::from_ms(-9.0, 2.0))) + .expect("new"); + // Sets neither: the rule supplies both. + h.register(Member::new("b")).expect("new"); + + let a = h.rating("a").expect("registered"); + assert_eq!(a.prior().mu(), -9.0, "explicit prior wins"); + assert_eq!(a.drift_scale(), 0.0, "the rule's drift_scale survives"); + + let b = h.rating("b").expect("registered"); + assert_eq!(b.prior().mu(), 5.0); + assert_eq!(b.drift_scale(), 0.0); +} + +#[test] +fn two_explicit_declarations_that_disagree_are_still_an_error() { + // Precedence resolves rule-vs-explicit. It does not weaken the check + // between two explicit declarations, neither of which is more specific. + let mut h = History::builder() + .default_rating_for(|_: &&'static str| Some(pinned())) + .build(); + + let err = h + .add_events(vec![ + event(1, "x", Gaussian::from_ms(1.0, 1.0)), + event(2, "x", Gaussian::from_ms(2.0, 1.0)), + ]) + .expect_err("two different priors for one competitor"); + assert!( + matches!(err, InferenceError::ConflictingCompetitorConfig { .. }), + "{err:?}" + ); +} + +fn event(time: i64, key: &'static str, prior: Gaussian) -> trueskill_tt::Event { + trueskill_tt::Event { + time, + teams: [ + trueskill_tt::Team::with_members([Member::new(key).with_prior(prior)]), + trueskill_tt::Team::with_members([Member::new("opponent")]), + ] + .into_iter() + .collect(), + outcome: trueskill_tt::Outcome::scores([2.0, 1.0]), + } +} + +/// A named rule type, so the `History<..>` can be written down in a field. +struct StaticLayouts; + +impl RatingRule<&'static str> for StaticLayouts { + fn starting_point(&self, key: &&'static str) -> Option { + key.starts_with("layout_").then(pinned) + } +} + +/// The reason this is a trait rather than a bare `Fn` bound: a consumer holds +/// its history in application state and has to name the type. +struct Ladder { + history: History<&'static str, i64, ConstantDrift, NullObserver, StaticLayouts>, +} + +#[test] +fn a_named_rule_type_can_be_stored_in_a_struct_field() { + let mut ladder = Ladder { + history: HistoryBuilder::default().rating_rule(StaticLayouts).build(), + }; + play(&mut ladder.history); + + assert!( + ladder + .history + .current_skill("layout_a") + .expect("played") + .sigma() + < 0.5 + ); + assert_eq!( + ladder + .history + .rating("alice") + .expect("played") + .drift_scale(), + 1.0 + ); +} + +#[test] +fn no_rule_is_the_default_and_costs_nothing_to_spell() { + // The whole point of defaulting the parameter: `History` still works. + let h: History = History::builder().key_type::().build(); + assert_eq!(h.competitor_count(), 0); +}