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,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<R: RatingRule<&'static str>>(
|
||||
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<i64, &'static str> {
|
||||
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<StartingPoint> {
|
||||
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<K>` still works.
|
||||
let h: History<String> = History::builder().key_type::<String>().build();
|
||||
assert_eq!(h.competitor_count(), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user