feat: HistoryBuilder::default_rating_for, a rule instead of a roll call
`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<String>` 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<Rating<T, D>>`. 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<K>` 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<K>` free of
`T` and `D`: with `Rating<T, D>` in the signature, `drift` and
`time_type` stop compiling after a rule is set, because
`R: RatingRule<K, T, D>` does not imply `R: RatingRule<K, T, D2>`.
**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<K>` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
@@ -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<String>` 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<Gaussian>,
|
||||
pub(crate) drift_scale: Option<f64>,
|
||||
}
|
||||
|
||||
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<K> {
|
||||
/// Where this competitor should start, or `None` for the history's
|
||||
/// defaults.
|
||||
fn starting_point(&self, key: &K) -> Option<StartingPoint>;
|
||||
}
|
||||
|
||||
/// The default rule: no opinion about anybody.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct NoRule;
|
||||
|
||||
impl<K> RatingRule<K> for NoRule {
|
||||
#[inline]
|
||||
fn starting_point(&self, _key: &K) -> Option<StartingPoint> {
|
||||
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<F>(pub F);
|
||||
|
||||
impl<K, F: Fn(&K) -> Option<StartingPoint>> RatingRule<K> for FnRule<F> {
|
||||
#[inline]
|
||||
fn starting_point(&self, key: &K) -> Option<StartingPoint> {
|
||||
(self.0)(key)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user