From 8c087ad015494477d62694c6b9948c6a60f0160c Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 7 Sep 2026 15:42:31 +0200 Subject: [PATCH] fix!: apply competitor configuration whenever it is supplied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Member::with_prior` and `with_drift_scale` were consumed only on the branch that *creates* a competitor — `priors.remove` sat inside `if !self.agents.contains(..)`. Supplying either for a key the history already knew did nothing at all: no error, no warning, and output computed from the default prior. A prior applied on a competitor's very first event and was silently discarded ever after. Configuration now applies whenever supplied. Two details this forced: Configuration is tracked per *field* rather than as a merged `Rating`. A member setting only `drift_scale` must not also assert the default prior, or it would silently undo a prior seeded on an earlier event. Slice state has to be refreshed. `drift_scale` is re-derived on every forward pass, but a prior is written into the competitor's earliest slice once, at ingestion, and `iteration` refreshes only slices after the first. Without the refresh a late prior would reach the drift terms and nothing else — a subtler version of the drop being fixed. This was caught by a test, not by reading the code. Conflicting values for one competitor within a single batch are now `ConflictingCompetitorConfig` rather than resolved by iteration order. Events in a batch are unordered, so "last one wins" would make the result depend on traversal — and `tests/ingestion_equivalence.rs` exists to rule exactly that out. Repeating the same value stays inert, which is the shape callers get when configuration is a property of the domain. That invariant turned out to be tested only for *unconfigured* competitors: every helper in that file built members with `Member::new`. Extended to cover configured ones, including a check that configuration changes the fit at all, so the order tests cannot pass vacuously. `with_prior` had no coverage under `tests/` whatsoever, which is how this survived. Adds `tests/competitor_config.rs`. `drift_scale_is_ignored_after_first_appearance` asserted the old behaviour and now asserts the new one. It was written as a deliberate change-detector — "moving the capture would be a visible break, not a silent one" — so it inverted rather than being deleted. Also removes `InferenceError::ConvergenceFailed` and `NegativePrecision`, which no code path ever constructed: public variants advertising failure modes no caller could observe. Partial #20 — its other items were already resolved, except `Outcome::winner` still panicking. BREAKING CHANGE: `prior` and `drift_scale` now take effect for competitors the history already knows, where they were previously ignored; a batch supplying conflicting values for one competitor is now an error. `InferenceError::ConvergenceFailed` and `InferenceError::NegativePrecision` are removed. Closes #10. Refs #20. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ --- src/error.rs | 30 ++--- src/event.rs | 14 ++- src/history.rs | 118 ++++++++++++++---- tests/competitor_config.rs | 222 +++++++++++++++++++++++++++++++++ tests/drift_scale.rs | 127 ++++++++++++++++--- tests/ingestion_equivalence.rs | 78 ++++++++++++ 6 files changed, 534 insertions(+), 55 deletions(-) create mode 100644 tests/competitor_config.rs diff --git a/src/error.rs b/src/error.rs index 86c6618..eb038ef 100644 --- a/src/error.rs +++ b/src/error.rs @@ -25,11 +25,6 @@ pub enum InferenceError { /// result has no representable likelihood. Configure a positive `p_draw` /// (via `HistoryBuilder::p_draw` or `GameOptions::p_draw`) to admit ties. TieWithoutDrawProbability { teams: (usize, usize) }, - /// Convergence exceeded `max_iter` without falling below `epsilon`. - ConvergenceFailed { - last_step: (f64, f64), - iterations: usize, - }, /// Inference produced a non-finite value (NaN or infinity). /// /// Indicates numerical breakdown; the resulting skills are meaningless @@ -38,8 +33,19 @@ pub enum InferenceError { context: &'static str, step: (f64, f64), }, - /// Negative precision: a Gaussian with `pi < 0` slipped into an API call. - NegativePrecision { pi: f64 }, + /// One batch declared two different values for the same competitor's + /// configuration. + /// + /// `prior` and `drift_scale` configure a competitor, not an event, so a + /// batch that sets one of them twice with different values has no + /// well-defined meaning: events within a batch are not ordered, so + /// "last one wins" would make the result depend on iteration order. + /// Declaring the same value repeatedly is fine and is the expected shape + /// when a competitor's configuration is a property of the domain. + ConflictingCompetitorConfig { + competitor: usize, + field: &'static str, + }, /// A prediction referenced a key the history has no skill for. /// /// Reported rather than skipped: dropping unknown keys turns a team of @@ -96,18 +102,12 @@ impl fmt::Display for InferenceError { Self::InvalidParameter { name, value } => { write!(f, "{name} is invalid: {value}") } - Self::ConvergenceFailed { - last_step, - iterations, - } => { + Self::ConflictingCompetitorConfig { competitor, field } => { write!( f, - "convergence failed after {iterations} iterations; last step = {last_step:?}" + "competitor {competitor}: this batch sets {field} to two different values" ) } - Self::NegativePrecision { pi } => { - write!(f, "precision must be non-negative; got {pi}") - } Self::UnknownKey { team, member } => { write!( f, diff --git a/src/event.rs b/src/event.rs index 56a2393..d7ed014 100644 --- a/src/event.rs +++ b/src/event.rs @@ -50,9 +50,17 @@ impl Default for Team { /// `weight` applies per event and defaults to 1.0. /// /// `prior` and `drift_scale` are **competitor configuration**, not per-event -/// values: both are captured when the competitor is first created and ignored -/// on every later appearance. Setting either on a key the history already knows -/// has no effect. +/// values. Setting either applies to the competitor for the whole history, not +/// just to this event, and applies whenever it is supplied — including on a key +/// the history already knows. Because configuration lives on the competitor and +/// `converge` refits from competitor state, configuring one late still refits +/// the whole history rather than taking effect only from that event onward. +/// +/// Repeating the same value is inert, which is the expected shape when the +/// configuration is a property of the domain. Supplying two *different* values +/// for one competitor within a single batch is +/// `InferenceError::ConflictingCompetitorConfig`: events in a batch have no +/// order, so there would be no well-defined winner. #[derive(Clone, Debug)] pub struct Member { pub key: K, diff --git a/src/history.rs b/src/history.rs index 36d025e..fb08e5b 100644 --- a/src/history.rs +++ b/src/history.rs @@ -172,6 +172,23 @@ impl Default for HistoryBuilder } } +/// Configuration a caller attached to a competitor via `Member`. +/// +/// Carries *what was explicitly set* rather than a merged `Rating`, so a member +/// that sets only `drift_scale` does not also assert the default prior — which +/// would spuriously conflict with a prior seeded on an earlier event. +#[derive(Clone, Copy, Default)] +pub(crate) struct CompetitorConfig { + prior: Option, + drift_scale: Option, +} + +impl CompetitorConfig { + fn is_empty(self) -> bool { + self.prior.is_none() && self.drift_scale.is_none() + } +} + pub struct History< T: Time = i64, D: Drift = ConstantDrift, @@ -876,7 +893,7 @@ impl, O: Observer, K: Eq + Hash + Clone> History, mut weights: Option>>>, kinds: Vec, - mut priors: HashMap>, + priors: HashMap, ) -> Result<(), InferenceError> { if results .as_ref() @@ -943,17 +960,60 @@ impl, O: Observer, K: Eq + Hash + Clone> History, O: Observer, K: Eq + Hash + Clone> History = Vec::with_capacity(events.len()); let mut weights: Vec>> = Vec::with_capacity(events.len()); let mut kinds: Vec = Vec::with_capacity(events.len()); - let mut priors: HashMap> = HashMap::new(); + let mut priors: HashMap = HashMap::new(); for ev in events { if ev.outcome.team_count() != ev.teams.len() { @@ -1204,23 +1264,37 @@ impl, O: Observer, K: Eq + Hash + Clone> History History { + History::builder() + .mu(25.0) + .sigma(25.0 / 3.0) + .beta(25.0 / 6.0) + .p_draw(0.0) + .convergence(CONVERGENCE) + .build() +} + +/// One event, optionally configuring `a`. +fn bout( + a: &'static str, + b: &'static str, + time: i64, + prior: Option, + scale: Option, +) -> Event { + let mut member = Member::new(a); + if let Some(p) = prior { + member = member.with_prior(p); + } + if let Some(s) = scale { + member = member.with_drift_scale(s); + } + + Event { + time, + teams: smallvec![ + Team::with_members([member]), + Team::with_members([Member::new(b)]), + ], + outcome: Outcome::winner(0, 2), + } +} + +fn skill_of(h: &History, key: &str) -> Gaussian { + h.current_skill(&key).expect("key in history") +} + +/// Baseline: the mechanism works at all on a competitor's first appearance. +#[test] +fn a_prior_applies_to_a_new_competitor() { + let seeded = Gaussian::from_ms(40.0, 1.0); + + let mut with = history(); + with.add_events(vec![bout("a", "b", 0, Some(seeded), None)]) + .unwrap(); + with.converge().unwrap(); + + let mut without = history(); + without + .add_events(vec![bout("a", "b", 0, None, None)]) + .unwrap(); + without.converge().unwrap(); + + assert!( + (skill_of(&with, "a").mu() - skill_of(&without, "a").mu()).abs() > 1.0, + "a seeded prior should move the fit" + ); +} + +/// The defect in #10: a prior supplied for a competitor the history already +/// knows was silently discarded, and the caller got output computed from the +/// default prior with no indication anything had been dropped. +#[test] +fn a_prior_applies_to_a_competitor_the_history_already_knows() { + let seeded = Gaussian::from_ms(40.0, 1.0); + + let mut late = history(); + late.add_events(vec![bout("a", "b", 0, None, None)]) + .unwrap(); + // "a" now exists. Configuring it here used to do nothing whatsoever. + late.add_events(vec![bout("a", "b", 1, Some(seeded), None)]) + .unwrap(); + late.converge().unwrap(); + + let mut never = history(); + never + .add_events(vec![ + bout("a", "b", 0, None, None), + bout("a", "b", 1, None, None), + ]) + .unwrap(); + never.converge().unwrap(); + + assert!( + (skill_of(&late, "a").mu() - skill_of(&never, "a").mu()).abs() > 1.0, + "a late prior must not be silently dropped: {} vs {}", + skill_of(&late, "a").mu(), + skill_of(&never, "a").mu() + ); +} + +/// Configuration is competitor-scoped, not event-scoped, and `converge` refits +/// from competitor state — so seeding late reaches the same fit as seeding from +/// the start. This is the documented scope, asserted rather than assumed. +#[test] +fn a_prior_is_whole_history_scoped_not_per_event() { + let seeded = Gaussian::from_ms(40.0, 1.0); + + let mut late = history(); + late.add_events(vec![bout("a", "b", 0, None, None)]) + .unwrap(); + late.add_events(vec![bout("a", "b", 1, Some(seeded), None)]) + .unwrap(); + late.converge().unwrap(); + + let mut early = history(); + early + .add_events(vec![ + bout("a", "b", 0, Some(seeded), None), + bout("a", "b", 1, Some(seeded), None), + ]) + .unwrap(); + early.converge().unwrap(); + + let (l, e) = (skill_of(&late, "a"), skill_of(&early, "a")); + assert!( + (l.mu() - e.mu()).abs() < 1e-9 && (l.sigma() - e.sigma()).abs() < 1e-9, + "late seeding should refit the whole history: {l:?} vs {e:?}" + ); +} + +#[test] +fn repeating_the_same_prior_is_inert() { + let seeded = Gaussian::from_ms(40.0, 1.0); + + let mut once = history(); + once.add_events(vec![ + bout("a", "b", 0, Some(seeded), None), + bout("a", "b", 1, None, None), + ]) + .unwrap(); + once.converge().unwrap(); + + let mut every_time = history(); + every_time + .add_events(vec![ + bout("a", "b", 0, Some(seeded), None), + bout("a", "b", 1, Some(seeded), None), + ]) + .unwrap(); + every_time.converge().unwrap(); + + let (o, e) = (skill_of(&once, "a"), skill_of(&every_time, "a")); + assert!( + (o.mu() - e.mu()).abs() < 1e-12 && (o.sigma() - e.sigma()).abs() < 1e-12, + "declaring the same prior repeatedly changed the fit: {o:?} vs {e:?}" + ); +} + +/// Events within a batch have no order, so two different values for one +/// competitor have no well-defined winner. Rejecting is what keeps the answer +/// independent of iteration order. +#[test] +fn a_batch_declaring_two_different_priors_is_rejected() { + let mut h = history(); + let err = h + .add_events(vec![ + bout("a", "b", 0, Some(Gaussian::from_ms(40.0, 1.0)), None), + bout("a", "b", 1, Some(Gaussian::from_ms(10.0, 1.0)), None), + ]) + .expect_err("two different priors for one competitor in one batch"); + + assert!( + matches!( + err, + InferenceError::ConflictingCompetitorConfig { field: "prior", .. } + ), + "got {err:?}" + ); +} + +/// A member setting only `drift_scale` must not also assert the default prior, +/// or it would silently undo a prior seeded earlier. This is why the collected +/// configuration tracks each field separately rather than a merged `Rating`. +#[test] +fn setting_one_field_late_leaves_the_other_alone() { + let seeded = Gaussian::from_ms(40.0, 1.0); + + let mut h = history(); + h.add_events(vec![bout("a", "b", 0, Some(seeded), None)]) + .unwrap(); + // Only the scale this time — the prior above must survive. + h.add_events(vec![bout("a", "b", 1, None, Some(0.5))]) + .unwrap(); + h.converge().unwrap(); + + let mut both_upfront = history(); + both_upfront + .add_events(vec![ + bout("a", "b", 0, Some(seeded), Some(0.5)), + bout("a", "b", 1, None, None), + ]) + .unwrap(); + both_upfront.converge().unwrap(); + + let (a, b) = (skill_of(&h, "a"), skill_of(&both_upfront, "a")); + assert!( + (a.mu() - b.mu()).abs() < 1e-9 && (a.sigma() - b.sigma()).abs() < 1e-9, + "setting drift_scale late clobbered the earlier prior: {a:?} vs {b:?}" + ); +} diff --git a/tests/drift_scale.rs b/tests/drift_scale.rs index c73444c..ff60bb8 100644 --- a/tests/drift_scale.rs +++ b/tests/drift_scale.rs @@ -341,13 +341,20 @@ fn zero_scale_pins_a_competitor_in_the_filtered_pass() { ); } -/// `drift_scale` is competitor configuration captured at first appearance, the -/// same as `prior` — a later `with_drift_scale` on a key the history already -/// knows is ignored. This guards that decision rather than driving it: the -/// behaviour falls out of where the capture happens, and the point of the test -/// is that moving the capture would be a visible break, not a silent one. +/// `drift_scale` is competitor configuration, and configuration supplied for a +/// competitor the history already knows is now *applied* rather than dropped. +/// +/// This test previously asserted the opposite. It was written as a deliberate +/// change-detector — "moving the capture would be a visible break, not a silent +/// one" — and that is exactly what happened: the capture moved, and the +/// assertion inverted rather than being deleted. +/// +/// Because configuration lives on the competitor and `converge` refits from +/// competitor state, a late pin applies to the *whole* history, not just to +/// events after it. So a scale set on the second batch must reach the same fit +/// as one set from the very first event. #[test] -fn drift_scale_is_ignored_after_first_appearance() { +fn drift_scale_applies_when_set_after_first_appearance() { let mut late = History::builder() .mu(25.0) .sigma(25.0 / 3.0) @@ -368,7 +375,7 @@ fn drift_scale_is_ignored_after_first_appearance() { }]) .unwrap(); - // Second batch asks for a pin. Too late: the competitor already exists. + // Second batch asks for a pin. No longer too late. late.add_events(vec![Event { time: 1000, teams: smallvec![ @@ -380,23 +387,113 @@ fn drift_scale_is_ignored_after_first_appearance() { .unwrap(); late.converge().unwrap(); - let ignored = curve(&late, "anchor"); - let drifting = curve(&fit(distant_pair(None), 25.0 / 300.0), "anchor"); + let applied = curve(&late, "anchor"); + let pinned_from_the_start = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor"); + let never_pinned = curve(&fit(distant_pair(None), 25.0 / 300.0), "anchor"); - for ((t_l, g_l), (t_r, g_r)) in ignored.iter().zip(drifting.iter()) { + for ((t_l, g_l), (t_r, g_r)) in applied.iter().zip(pinned_from_the_start.iter()) { assert_eq!(t_l, t_r); assert!( (g_l.sigma() - g_r.sigma()).abs() < 1e-9, - "a scale set after first appearance must be ignored, leaving the fit \ - identical to one that never set it: t={t_l}, {} vs {}", + "a late pin should refit the whole history: t={t_l}, {} vs {}", g_l.sigma(), g_r.sigma() ); } - let pinned = curve(&fit(distant_pair(Some(0.0)), 25.0 / 300.0), "anchor"); + // And it must actually have done something. assert!( - (ignored[1].1.sigma() - pinned[1].1.sigma()).abs() > 1e-6, - "sanity: the pinned fit must actually differ, or the assertion above is vacuous" + applied + .iter() + .zip(never_pinned.iter()) + .any(|((_, a), (_, b))| (a.sigma() - b.sigma()).abs() > 1e-9), + "the pin had no effect at all — the silent drop is back" + ); +} + +/// Re-declaring the same configuration must be inert. This is the shape a +/// caller gets when the configuration is a property of the domain — "layouts +/// are static" — so every ingestion path repeats it on every event. +/// +/// Both histories see exactly the same events; only how many times the scale +/// is declared differs. +#[test] +fn repeating_the_same_configuration_changes_nothing() { + let events = |declare_every_time: bool| { + let anchor = |first: bool| { + if first || declare_every_time { + Member::new("anchor").with_drift_scale(0.0) + } else { + Member::new("anchor") + } + }; + vec![ + Event { + time: 0, + teams: smallvec![ + Team::with_members([anchor(true)]), + Team::with_members([Member::new("player")]), + ], + outcome: Outcome::winner(0, 2), + }, + Event { + time: 1000, + teams: smallvec![ + Team::with_members([anchor(false)]), + Team::with_members([Member::new("player")]), + ], + outcome: Outcome::winner(1, 2), + }, + ] + }; + + let once = curve(&fit(events(false), 25.0 / 300.0), "anchor"); + let every_time = curve(&fit(events(true), 25.0 / 300.0), "anchor"); + + for ((t_l, a), (t_r, b)) in once.iter().zip(every_time.iter()) { + assert_eq!(t_l, t_r); + assert!( + (a.sigma() - b.sigma()).abs() < 1e-12, + "t={t_l}: declaring the same scale repeatedly changed the fit, {} vs {}", + a.sigma(), + b.sigma() + ); + } +} + +#[test] +fn a_batch_that_contradicts_itself_is_rejected() { + let mut h = History::builder().convergence(CONVERGENCE).build(); + + let err = h + .add_events(vec![ + Event { + time: 0, + teams: smallvec![ + Team::with_members([Member::new("anchor").with_drift_scale(0.0)]), + Team::with_members([Member::new("player")]), + ], + outcome: Outcome::winner(0, 2), + }, + Event { + time: 1, + teams: smallvec![ + Team::with_members([Member::new("anchor").with_drift_scale(1.0)]), + Team::with_members([Member::new("player")]), + ], + outcome: Outcome::winner(0, 2), + }, + ]) + .expect_err("two different scales for one competitor in one batch"); + + assert!( + matches!( + err, + InferenceError::ConflictingCompetitorConfig { + field: "drift_scale", + .. + } + ), + "got {err:?}" ); } diff --git a/tests/ingestion_equivalence.rs b/tests/ingestion_equivalence.rs index afd6b31..c50f708 100644 --- a/tests/ingestion_equivalence.rs +++ b/tests/ingestion_equivalence.rs @@ -30,6 +30,22 @@ fn event(a: &str, b: &str, time: i64) -> Event { } } +/// Like [`event`], but `a` carries competitor configuration. +/// +/// `prior` and `drift_scale` configure the competitor rather than the event, so +/// they are the part of ingestion most exposed to order: they are consumed once, +/// where the competitor's state is written. +fn configured_event(a: &str, b: &str, time: i64, scale: f64) -> Event { + Event { + time, + teams: smallvec![ + Team::with_members([Member::new(a.to_string()).with_drift_scale(scale)]), + Team::with_members([Member::new(b.to_string())]), + ], + outcome: Outcome::winner(0, 2), + } +} + fn converged_skills(events: Vec>, batched: bool) -> Vec<(String, Gaussian)> { let mut h: History = History::builder_with_key().convergence(tight()).build(); @@ -145,3 +161,65 @@ fn back_dated_event_matches_batched() { let incremental = converged_skills(events, false); assert_same(&batched, &incremental, "back-dated event"); } + +/// The invariant this file protects was only ever checked for *unconfigured* +/// competitors — every helper above built members with `Member::new`. +/// +/// Configuration is the part most exposed to ordering, because it is consumed +/// once at the point the competitor's state is written rather than replayed per +/// event. These cover it. +#[test] +fn configured_competitors_are_order_independent() { + let events = vec![ + configured_event("a", "b", 0, 0.0), + configured_event("a", "c", 1, 0.0), + configured_event("a", "b", 2, 0.0), + event("b", "c", 3), + ]; + + assert_same( + &converged_skills(events.clone(), true), + &converged_skills(events, false), + "configuration repeated on every appearance", + ); +} + +/// Configuration supplied only on a *later* event is the case that used to be +/// silently dropped. It must now reach the same fit either way it is ingested. +#[test] +fn late_configuration_is_order_independent() { + let events = vec![ + event("a", "b", 0), + configured_event("a", "c", 1, 0.0), + event("a", "b", 2), + ]; + + assert_same( + &converged_skills(events.clone(), true), + &converged_skills(events, false), + "configuration supplied after first appearance", + ); +} + +/// And it must actually be doing something — an implementation that dropped +/// configuration entirely would pass both tests above. +#[test] +fn configuration_changes_the_fit_however_it_is_ingested() { + let configured = vec![ + event("a", "b", 0), + configured_event("a", "c", 1, 0.0), + event("a", "b", 2), + ]; + let plain = vec![event("a", "b", 0), event("a", "c", 1), event("a", "b", 2)]; + + for batched in [true, false] { + let with = converged_skills(configured.clone(), batched); + let without = converged_skills(plain.clone(), batched); + assert!( + with.iter() + .zip(&without) + .any(|((_, x), (_, y))| (x.sigma() - y.sigma()).abs() > 1e-9), + "batched={batched}: configuration had no effect, so the order tests are vacuous" + ); + } +}