Allow drift to vary per competitor: Member::with_drift_scale #34

Closed
opened 2026-09-01 04:11:11 +00:00 by logaritmisk · 0 comments
Owner

Drift is currently a property of the History, so every competitor in a graph drifts at
the same rate. There is no way to hold one competitor still while others move.

Why the trait cannot express it today

src/drift.rs:

pub trait Drift<T: Time>: Copy + Debug + Send + Sync {
    fn variance_delta(&self, from: &T, to: &T) -> f64;
    fn variance_for_elapsed(&self, elapsed: i64) -> f64;
}

Neither method sees the competitor. And history.rs:720 hands the same drift to every
one of them:

priors.insert(idx, Rating::new(prior, self.beta, self.drift));

So the storage is already per-competitor — Rating carries a drift — but nothing in
the API can set it.

The use case: anchor competitors

Any model with a fixed reference point in the same graph as moving competitors hits
this. A bot at a known strength. A rating floor. A difficulty level. In every case the
reference should be static by construction while the players around it drift, and today
it drifts with them.

The concrete instance: ustat is being rebuilt so that disc-golf layouts and holes are
competitors alongside players, which is what puts skill and course difficulty on one
scale and makes "what would you shoot on a layout you have never played" answerable.
That requires:

Competitor Should drift
player yes — form changes
player×layout / player×hole offset slowly — how a hole suits your throw changes less than your form
layout / hole difficulty no — a course does not improve
per-round form deviation no — a one-off

The static case is not a nicety there. A solo round is one observation against two
unknowns; if the layout may drift, that round cannot say which of them was responsible
and the model splits it arbitrarily. A static layout is already pinned by other rounds,
so the observation lands on the player. Roughly a third of the rounds in that dataset are
solo, so "difficulty is static" is what makes them usable at all.

Proposed API

A per-member multiplier on the history's drift, mirroring the existing
with_prior / with_weight:

Member::new(key)
    .with_drift_scale(0.0)   // static: this competitor never drifts
pub struct Member<K> {
    pub key: K,
    pub weight: f64,
    pub prior: Option<Gaussian>,
    pub drift_scale: Option<f64>,   // new; None == 1.0
}

Applied wherever drift variance is currently accumulated:

variance += drift.variance_for_elapsed(elapsed) * scale * scale;

A multiplier rather than a replacement Drift, deliberately. History is generic
over a single D: Drift<T>, so competitors cannot each carry a different Drift type
without erasing it or boxing. A scalar sidesteps that entirely, stays Copy, and keeps
the existing type parameter untouched.

Squared because it scales a variance, so scale is in the same units as gamma
ConstantDrift(g) with scale = s behaves exactly as ConstantDrift(g * s) would for
that competitor. That makes it composable and easy to reason about.

Backward compatibility

None means 1.0, so existing behaviour is unchanged and no call site needs touching.
Same shape as prior, which is already Option.

Where it needs threading

  • src/event.rs — the field and builder method on Member
  • src/history.rs:463 add_events_with_prior — capture the scale at a competitor's
    first appearance, alongside the prior it already captures there
  • wherever variance_for_elapsed / variance_delta are applied per competitor
    (history.rs around the forward/backward prior passes, time_slice.rs)

Worth deciding explicitly: like prior, is the scale a property captured at first
appearance, or a per-event override? I would argue first appearance, because a
competitor that is static is static — a scale that changed between events would make the
skill trajectory hard to interpret. But prior is documented as a per-event override, so
the two should probably be consistent, or the difference should be documented.

Suggested tests

  • static competitor holds still: two events far apart in time; a competitor with
    scale = 0.0 has identical sigma at both, while one at the default has grown.
  • scale matches an equivalent gamma: ConstantDrift(0.3) with scale = 0.5 produces
    the same posterior as ConstantDrift(0.15) with the default, for an otherwise
    identical history.
  • default is unchanged: an existing fit produces byte-identical results with no
    drift_scale set anywhere.
  • mixed graph: static and drifting competitors in one event, converging without the
    static one absorbing drift through its neighbours.

Note

Happy to implement this if you would rather review a PR than a description — the ustat
side is blocked on it either way, and the shape above is what that work needs.

Drift is currently a property of the `History`, so every competitor in a graph drifts at the same rate. There is no way to hold one competitor still while others move. ## Why the trait cannot express it today `src/drift.rs`: ```rust pub trait Drift<T: Time>: Copy + Debug + Send + Sync { fn variance_delta(&self, from: &T, to: &T) -> f64; fn variance_for_elapsed(&self, elapsed: i64) -> f64; } ``` Neither method sees the competitor. And `history.rs:720` hands the same drift to every one of them: ```rust priors.insert(idx, Rating::new(prior, self.beta, self.drift)); ``` So the *storage* is already per-competitor — `Rating` carries a drift — but nothing in the API can set it. ## The use case: anchor competitors Any model with a **fixed reference point** in the same graph as moving competitors hits this. A bot at a known strength. A rating floor. A difficulty level. In every case the reference should be static by construction while the players around it drift, and today it drifts with them. The concrete instance: `ustat` is being rebuilt so that disc-golf layouts and holes are competitors alongside players, which is what puts skill and course difficulty on one scale and makes "what would you shoot on a layout you have never played" answerable. That requires: | Competitor | Should drift | |---|---| | player | yes — form changes | | player×layout / player×hole offset | slowly — how a hole suits your throw changes less than your form | | layout / hole difficulty | **no** — a course does not improve | | per-round form deviation | **no** — a one-off | The static case is not a nicety there. A solo round is one observation against two unknowns; if the layout may drift, that round cannot say which of them was responsible and the model splits it arbitrarily. A static layout is already pinned by other rounds, so the observation lands on the player. Roughly a third of the rounds in that dataset are solo, so "difficulty is static" is what makes them usable at all. ## Proposed API A per-member **multiplier** on the history's drift, mirroring the existing `with_prior` / `with_weight`: ```rust Member::new(key) .with_drift_scale(0.0) // static: this competitor never drifts ``` ```rust pub struct Member<K> { pub key: K, pub weight: f64, pub prior: Option<Gaussian>, pub drift_scale: Option<f64>, // new; None == 1.0 } ``` Applied wherever drift variance is currently accumulated: ```rust variance += drift.variance_for_elapsed(elapsed) * scale * scale; ``` **A multiplier rather than a replacement `Drift`**, deliberately. `History` is generic over a single `D: Drift<T>`, so competitors cannot each carry a different `Drift` type without erasing it or boxing. A scalar sidesteps that entirely, stays `Copy`, and keeps the existing type parameter untouched. Squared because it scales a **variance**, so `scale` is in the same units as `gamma` — `ConstantDrift(g)` with `scale = s` behaves exactly as `ConstantDrift(g * s)` would for that competitor. That makes it composable and easy to reason about. ## Backward compatibility `None` means 1.0, so existing behaviour is unchanged and no call site needs touching. Same shape as `prior`, which is already `Option`. ## Where it needs threading - `src/event.rs` — the field and builder method on `Member` - `src/history.rs:463` `add_events_with_prior` — capture the scale at a competitor's first appearance, alongside the prior it already captures there - wherever `variance_for_elapsed` / `variance_delta` are applied per competitor (`history.rs` around the forward/backward prior passes, `time_slice.rs`) Worth deciding explicitly: like `prior`, is the scale a property captured at first appearance, or a per-event override? I would argue **first appearance**, because a competitor that is static is static — a scale that changed between events would make the skill trajectory hard to interpret. But `prior` is documented as a per-event override, so the two should probably be consistent, or the difference should be documented. ## Suggested tests - **static competitor holds still**: two events far apart in time; a competitor with `scale = 0.0` has identical sigma at both, while one at the default has grown. - **scale matches an equivalent gamma**: `ConstantDrift(0.3)` with `scale = 0.5` produces the same posterior as `ConstantDrift(0.15)` with the default, for an otherwise identical history. - **default is unchanged**: an existing fit produces byte-identical results with no `drift_scale` set anywhere. - **mixed graph**: static and drifting competitors in one event, converging without the static one absorbing drift through its neighbours. ## Note Happy to implement this if you would rather review a PR than a description — the ustat side is blocked on it either way, and the shape above is what that work needs.
logaritmisk added the apienhancement labels 2026-09-07 13:53:38 +00:00
Sign in to join this conversation.