feat!: add History::register and History::rating, and reject config conflicts across batches
Three things #38 asked for, on a premise that had half dissolved. The
issue argued from "captured at first appearance", "missing it is silent"
and "missing it is permanent"; 8c087ad made configuration apply whenever
supplied and refit the whole history, so two of those are already gone.
What survived is the literal title — no way to say it before the first
event — plus the absence of any way to check.
`register(Member)` states configuration before anything is observed. It
takes the same `Member` ingestion takes, so there is one vocabulary
rather than two, and it creates the competitor immediately, which is what
makes it observable. It reaches a competitor first seen through
`record_winner`, the route #37 deliberately did not extend.
`rating(&key)` reads back what was stored. Every other accessor reports
what inference inferred; this reports what it was told, which is what
makes a configuration mistake detectable from outside the crate at all.
Conflicting configuration is now an error across batches, not only within
one. The `priors` map is rebuilt per `add_events` call, so a second batch
silently overwrote what a first declared, last-write-wins. That cut
directly against the invariant tests/ingestion_equivalence.rs exists to
protect: the same contradictory events errored when batched and
succeeded, order-dependently, when fed one at a time. Detection lives on
a new `declared` map on `History`, because a `Rating` cannot say whether
a value was chosen or inherited from the defaults — which is exactly the
distinction the check needs. Checked before anything mutates, so a
rejected batch leaves the history untouched.
`register` rejects a non-default `weight` rather than ignoring it. Weight
is per-event and has no meaning on a registration, and silently dropping
a field the caller set is the defect this whole area keeps producing.
The declarative `default_rating_for` closure is not here. It is the
better answer for ustat's actual case — thousands of keys matching a
rule, rather than enumerated — but it adds a `Fn` parameter to `History`,
which the issue itself flags as in tension with the crate's posture. That
wants its own decision rather than riding along.
BREAKING CHANGE: two different values for one competitor's `prior` or
`drift_scale` supplied across separate `add_events` calls now return
`ConflictingCompetitorConfig` instead of silently taking the later one.
Refs #38
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
@@ -118,6 +118,17 @@ pub enum InferenceError {
|
||||
member: usize,
|
||||
key: String,
|
||||
},
|
||||
/// `History::register` was called for a competitor that already exists.
|
||||
///
|
||||
/// Registration states a competitor's configuration before anything has
|
||||
/// been observed about them, so a competitor that already exists has
|
||||
/// already been configured — by an earlier `register`, or by an event that
|
||||
/// created them. Silently overwriting would reintroduce exactly the
|
||||
/// order-dependence registration exists to remove.
|
||||
///
|
||||
/// To change an existing competitor's configuration, supply it on an event
|
||||
/// through `Member`; that refits the whole history.
|
||||
AlreadyRegistered { key: String },
|
||||
/// A prediction was given a team with no members.
|
||||
EmptyTeam { team: usize },
|
||||
/// A joint posterior was requested where one cannot be formed exactly.
|
||||
@@ -197,6 +208,14 @@ impl fmt::Display for InferenceError {
|
||||
with `lookup` or `current_skill` if that is not guaranteed)"
|
||||
)
|
||||
}
|
||||
Self::AlreadyRegistered { key } => {
|
||||
write!(
|
||||
f,
|
||||
"competitor {key} is already registered; registration states \
|
||||
configuration before anything is observed, so re-registering \
|
||||
would silently overwrite it"
|
||||
)
|
||||
}
|
||||
Self::EmptyTeam { team } => {
|
||||
write!(f, "team {team} has no members")
|
||||
}
|
||||
|
||||
+157
-1
@@ -6,6 +6,7 @@ use crate::{
|
||||
convergence::{ConvergenceOptions, ConvergenceReport},
|
||||
drift::{ConstantDrift, Drift},
|
||||
error::InferenceError,
|
||||
event::Member,
|
||||
gaussian::Gaussian,
|
||||
key_table::KeyTable,
|
||||
observer::{NullObserver, Observer},
|
||||
@@ -207,6 +208,7 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> HistoryBuilder<
|
||||
convergence: self.convergence,
|
||||
observer: self.observer,
|
||||
unknown_keys: self.unknown_keys,
|
||||
declared: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,6 +316,12 @@ pub struct History<
|
||||
convergence: ConvergenceOptions,
|
||||
observer: O,
|
||||
unknown_keys: crate::UnknownKeys,
|
||||
/// Competitor configuration explicitly declared so far, by whichever route.
|
||||
///
|
||||
/// Kept separate from the applied `Rating` because a `Rating` cannot say
|
||||
/// whether a value was *chosen* or inherited from the history defaults,
|
||||
/// and that is exactly the distinction a conflict check needs.
|
||||
declared: HashMap<Index, CompetitorConfig>,
|
||||
}
|
||||
|
||||
impl Default for History<i64, ConstantDrift, NullObserver, &'static str> {
|
||||
@@ -493,6 +501,111 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
|
||||
/// Skill estimate at the latest time slice the competitor appears in.
|
||||
/// Configure a competitor before anything has been observed about them.
|
||||
///
|
||||
/// The configuration a competitor needs is often a property of the domain
|
||||
/// rather than of any one event — "every layout is static", "this bot sits
|
||||
/// at a known strength". Stating it per-event means every ingestion path
|
||||
/// has to remember it, and the fluent and two-argument paths could not
|
||||
/// state it at all.
|
||||
///
|
||||
/// ```
|
||||
/// # use trueskill_tt::{History, Member};
|
||||
/// let mut h = History::builder().build();
|
||||
/// h.register(Member::new("layout_7").with_drift_scale(0.0))?;
|
||||
///
|
||||
/// // Reaches a competitor first seen through any route, including the
|
||||
/// // two-argument one, which cannot carry configuration itself.
|
||||
/// h.record_winner(&"player", &"layout_7", 1)?;
|
||||
/// assert_eq!(h.rating(&"layout_7").unwrap().drift_scale(), 0.0);
|
||||
/// # Ok::<(), trueskill_tt::InferenceError>(())
|
||||
/// ```
|
||||
///
|
||||
/// The competitor exists from this point on, with no appearances, so
|
||||
/// [`History::rating`] can read back what was actually stored — the
|
||||
/// diagnostic that was previously missing entirely.
|
||||
///
|
||||
/// `weight` is per-event and has no meaning here, so a `Member` carrying a
|
||||
/// non-default one is rejected rather than silently ignored.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `AlreadyRegistered` if the competitor already exists, whether from an
|
||||
/// earlier `register` or from an event. `InvalidParameter` for a `weight`
|
||||
/// other than 1.0, or a `drift_scale` that is negative or non-finite.
|
||||
pub fn register(&mut self, member: Member<K>) -> Result<(), InferenceError>
|
||||
where
|
||||
K: std::fmt::Debug,
|
||||
{
|
||||
if member.weight != 1.0 {
|
||||
return Err(InferenceError::InvalidParameter {
|
||||
name: "weight",
|
||||
value: member.weight,
|
||||
});
|
||||
}
|
||||
if let Some(scale) = member.drift_scale {
|
||||
if !scale.is_finite() || scale < 0.0 {
|
||||
return Err(InferenceError::InvalidParameter {
|
||||
name: "drift_scale",
|
||||
value: scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let key = format!("{:?}", member.key);
|
||||
let idx = self.keys.get_or_create(&member.key);
|
||||
if self.agents.contains(idx) {
|
||||
return Err(InferenceError::AlreadyRegistered { key });
|
||||
}
|
||||
|
||||
let mut rating = Rating::new(
|
||||
Gaussian::from_ms(self.mu, self.sigma),
|
||||
self.beta,
|
||||
self.drift,
|
||||
);
|
||||
if let Some(prior) = member.prior {
|
||||
rating.prior = prior;
|
||||
}
|
||||
if let Some(scale) = member.drift_scale {
|
||||
rating.drift_scale = scale;
|
||||
}
|
||||
|
||||
self.declared.insert(
|
||||
idx,
|
||||
CompetitorConfig {
|
||||
prior: member.prior,
|
||||
drift_scale: member.drift_scale,
|
||||
},
|
||||
);
|
||||
self.agents.insert(
|
||||
idx,
|
||||
Competitor {
|
||||
rating,
|
||||
message: None,
|
||||
last_time: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The configuration in force for a competitor, or `None` if the history
|
||||
/// has never seen them.
|
||||
///
|
||||
/// Reads back what was actually stored, which is what makes a
|
||||
/// configuration mistake detectable from outside the crate. Every other
|
||||
/// accessor returns what inference *inferred*; this returns what it was
|
||||
/// told.
|
||||
#[must_use]
|
||||
pub fn rating<Q>(&self, key: &Q) -> Option<Rating<T, D>>
|
||||
where
|
||||
K: std::borrow::Borrow<Q>,
|
||||
Q: std::hash::Hash + Eq + ?Sized,
|
||||
{
|
||||
let idx = self.keys.get(key)?;
|
||||
self.agents.contains(idx).then(|| self.agents[idx].rating)
|
||||
}
|
||||
|
||||
pub fn current_skill<Q>(&self, key: &Q) -> Option<Gaussian>
|
||||
where
|
||||
K: std::borrow::Borrow<Q>,
|
||||
@@ -1647,6 +1760,47 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-batch conflict. The in-batch check upstream rejects one batch
|
||||
// that sets a field twice; `priors` is rebuilt per call, so without
|
||||
// this a *second* batch could quietly overwrite what a first one
|
||||
// declared, last-write-wins.
|
||||
//
|
||||
// That asymmetry cut against the invariant `tests/ingestion_equivalence.rs`
|
||||
// exists to protect: the same contradictory events errored when
|
||||
// batched and succeeded, order-dependently, when fed one at a time.
|
||||
// Checked before anything mutates, so a rejected batch leaves the
|
||||
// history untouched.
|
||||
for (agent, batch) in &priors {
|
||||
let held = self.declared.get(agent).copied().unwrap_or_default();
|
||||
|
||||
if let (Some(existing), Some(new)) = (held.prior, batch.prior) {
|
||||
if existing != new {
|
||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||
competitor: agent.get(),
|
||||
field: "prior",
|
||||
});
|
||||
}
|
||||
}
|
||||
if let (Some(existing), Some(new)) = (held.drift_scale, batch.drift_scale) {
|
||||
if existing != new {
|
||||
return Err(InferenceError::ConflictingCompetitorConfig {
|
||||
competitor: agent.get(),
|
||||
field: "drift_scale",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (agent, batch) in &priors {
|
||||
let entry = self.declared.entry(*agent).or_default();
|
||||
if batch.prior.is_some() {
|
||||
entry.prior = batch.prior;
|
||||
}
|
||||
if batch.drift_scale.is_some() {
|
||||
entry.drift_scale = batch.drift_scale;
|
||||
}
|
||||
}
|
||||
|
||||
competitor::clean(self.agents.values_mut(), true);
|
||||
|
||||
let mut this_agent = Vec::with_capacity(1024);
|
||||
@@ -1658,7 +1812,9 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
||||
|
||||
this_agent.push(*agent);
|
||||
|
||||
let config = priors.get(agent).copied().unwrap_or_default();
|
||||
// From `declared` rather than `priors`: a competitor configured by
|
||||
// `register` before any event has nothing in this batch's map.
|
||||
let config = self.declared.get(agent).copied().unwrap_or_default();
|
||||
|
||||
if self.agents.contains(*agent) {
|
||||
// Seeding a competitor the history already knows. This used to
|
||||
|
||||
Reference in New Issue
Block a user