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.
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
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:463add_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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Drift is currently a property of the
History, so every competitor in a graph drifts atthe same rate. There is no way to hold one competitor still while others move.
Why the trait cannot express it today
src/drift.rs:Neither method sees the competitor. And
history.rs:720hands the same drift to everyone of them:
So the storage is already per-competitor —
Ratingcarries a drift — but nothing inthe 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:
ustatis being rebuilt so that disc-golf layouts and holes arecompetitors 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:
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:Applied wherever drift variance is currently accumulated:
A multiplier rather than a replacement
Drift, deliberately.Historyis genericover a single
D: Drift<T>, so competitors cannot each carry a differentDrifttypewithout erasing it or boxing. A scalar sidesteps that entirely, stays
Copy, and keepsthe existing type parameter untouched.
Squared because it scales a variance, so
scaleis in the same units asgamma—ConstantDrift(g)withscale = sbehaves exactly asConstantDrift(g * s)would forthat competitor. That makes it composable and easy to reason about.
Backward compatibility
Nonemeans 1.0, so existing behaviour is unchanged and no call site needs touching.Same shape as
prior, which is alreadyOption.Where it needs threading
src/event.rs— the field and builder method onMembersrc/history.rs:463add_events_with_prior— capture the scale at a competitor'sfirst appearance, alongside the prior it already captures there
variance_for_elapsed/variance_deltaare applied per competitor(
history.rsaround the forward/backward prior passes,time_slice.rs)Worth deciding explicitly: like
prior, is the scale a property captured at firstappearance, 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
prioris documented as a per-event override, sothe two should probably be consistent, or the difference should be documented.
Suggested tests
scale = 0.0has identical sigma at both, while one at the default has grown.ConstantDrift(0.3)withscale = 0.5producesthe same posterior as
ConstantDrift(0.15)with the default, for an otherwiseidentical history.
drift_scaleset anywhere.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.