Files
logaritmiskandClaude Opus 5 c4194b0051 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
2026-09-10 07:17:12 +02:00

193 lines
6.1 KiB
Rust

//! `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);
}