prior and drift_scale are captured at a competitor's first appearance (#34). That decision is right — a competitor that is static is static, and a scale that changed between events would make the trajectory uninterpretable. What is missing is a reliable way to comply with it.
Today the only way to configure a competitor is to attach the config to whichever event happens to mention them first. There is no way to say it once, up front.
Why the current shape is fragile
The capture is at src/history.rs:987, and the consume is at :727:
rating: priors.remove(agent).unwrap_or_else(||{/* history defaults */}),
That remove runs only inside if !self.agents.contains(*agent). So:
Missing it is silent. A later .with_drift_scale(0.0) on a known key is dropped with no error and no warning. Nothing distinguishes "configured" from "configured too late".
Missing it is permanent. There is no way to fix it afterwards short of rebuilding the history from scratch.
It depends on ingestion order. With events batched, the config must be on the first batch mentioning the key; within one batch, last occurrence wins. Ingest the same events in a different order and a competitor can end up configured or not. That sits awkwardly beside the invariant tests/ingestion_equivalence.rs exists to protect.
It is not always expressible. If the first appearance arrives via record_winner/record_draw, or via EventBuilder, it cannot be set at all — see #37.
History::intern does not help. It touches only the KeyTable (src/history.rs:227-233) and never the CompetitorStore, so interning a key does not create a competitor — which is fortunate, since it means interning does not accidentally poison the capture, but it also means there is no existing pre-registration path to extend.
The motivating case
From #34: ustat treats disc-golf layouts and holes as competitors alongside players, with layouts static so a solo round's evidence lands on the player rather than being split. The correct statement there is a property of the domain — "every layout is static" — not of whichever round happens to be ingested first. Expressing it per-event means every ingestion path has to remember it forever, and forgetting once silently corrupts that competitor's entire trajectory with no diagnostic.
Possible shapes
// Explicit pre-registration, erroring if the competitor already exists.
h.register("layout_7",Rating::new(prior,beta,drift).with_drift_scale(0.0))?;// Or a rule evaluated at competitor creation, which suits the ustat case
// better than enumerating thousands of keys:
leth=History::builder().default_rating_for(|key: &str|{key.starts_with("layout_").then(||/* static rating */)}).build();
The closure form is the more interesting one: it makes "layouts are static" a single declarative statement that cannot be forgotten on an ingestion path, and it is evaluated exactly where the defaults are applied today (src/history.rs:727). It does add a Fn type parameter to History, which is worth weighing against the Copy-and-simple posture the crate otherwise keeps — a boxed closure or a small trait would avoid another generic.
Whichever lands, a diagnostic for the silent drop is worth having on its own, and is much cheaper: either return an error when a prior / drift_scale is supplied for a competitor that already exists, or expose a query (History::rating(&key)) so callers can assert what they got. Right now there is no way to check from outside the crate.
Tests
Registering before any event produces the same posterior as configuring on the first event.
Registering a key that already exists is an error, not a silent no-op.
A competitor first seen through record_winner still picks up a pre-registered rating — the case #37 cannot reach.
Pre-registration makes the fit independent of ingestion order, extending tests/ingestion_equivalence.rs to cover configured competitors.
`prior` and `drift_scale` are captured at a competitor's first appearance (#34). That decision is right — a competitor that is static is static, and a scale that changed between events would make the trajectory uninterpretable. What is missing is a reliable way to *comply* with it.
Today the only way to configure a competitor is to attach the config to whichever event happens to mention them first. There is no way to say it once, up front.
## Why the current shape is fragile
The capture is at `src/history.rs:987`, and the consume is at `:727`:
```rust
rating: priors.remove(agent).unwrap_or_else(|| { /* history defaults */ }),
```
That `remove` runs only inside `if !self.agents.contains(*agent)`. So:
- **Missing it is silent.** A later `.with_drift_scale(0.0)` on a known key is dropped with no error and no warning. Nothing distinguishes "configured" from "configured too late".
- **Missing it is permanent.** There is no way to fix it afterwards short of rebuilding the history from scratch.
- **It depends on ingestion order.** With events batched, the config must be on the first batch mentioning the key; within one batch, last occurrence wins. Ingest the same events in a different order and a competitor can end up configured or not. That sits awkwardly beside the invariant `tests/ingestion_equivalence.rs` exists to protect.
- **It is not always expressible.** If the first appearance arrives via `record_winner`/`record_draw`, or via `EventBuilder`, it cannot be set at all — see #37.
`History::intern` does not help. It touches only the `KeyTable` (`src/history.rs:227-233`) and never the `CompetitorStore`, so interning a key does not create a competitor — which is fortunate, since it means interning does not accidentally poison the capture, but it also means there is no existing pre-registration path to extend.
## The motivating case
From #34: `ustat` treats disc-golf layouts and holes as competitors alongside players, with layouts static so a solo round's evidence lands on the player rather than being split. The correct statement there is a property of the *domain* — "every layout is static" — not of whichever round happens to be ingested first. Expressing it per-event means every ingestion path has to remember it forever, and forgetting once silently corrupts that competitor's entire trajectory with no diagnostic.
## Possible shapes
```rust
// Explicit pre-registration, erroring if the competitor already exists.
h.register("layout_7", Rating::new(prior, beta, drift).with_drift_scale(0.0))?;
// Or a rule evaluated at competitor creation, which suits the ustat case
// better than enumerating thousands of keys:
let h = History::builder()
.default_rating_for(|key: &str| {
key.starts_with("layout_").then(|| /* static rating */)
})
.build();
```
The closure form is the more interesting one: it makes "layouts are static" a single declarative statement that cannot be forgotten on an ingestion path, and it is evaluated exactly where the defaults are applied today (`src/history.rs:727`). It does add a `Fn` type parameter to `History`, which is worth weighing against the `Copy`-and-simple posture the crate otherwise keeps — a boxed closure or a small trait would avoid another generic.
Whichever lands, **a diagnostic for the silent drop is worth having on its own**, and is much cheaper: either return an error when a `prior` / `drift_scale` is supplied for a competitor that already exists, or expose a query (`History::rating(&key)`) so callers can assert what they got. Right now there is no way to check from outside the crate.
## Tests
- Registering before any event produces the same posterior as configuring on the first event.
- Registering a key that already exists is an error, not a silent no-op.
- A competitor first seen through `record_winner` still picks up a pre-registered rating — the case #37 cannot reach.
- Pre-registration makes the fit independent of ingestion order, extending `tests/ingestion_equivalence.rs` to cover configured competitors.
Closing. The title claim is no longer true, and the closure form is #53.
What shipped
History::register(Member) states configuration before anything is observed:
h.register(Member::new("layout_7").with_drift_scale(0.0))?;h.record_winner(&"player",&"layout_7",1)?;// the route #37 could not reach
assert_eq!(h.rating(&"layout_7").unwrap().drift_scale(),0.0);
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 rather than a promise to apply something later.
History::rating(&key) is the read-back you asked for as the cheap diagnostic. 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.
Your four tests are in tests/registration.rs, including the one you singled out — a competitor first seen through record_winner picking up a pre-registered rating — and order-independence.
The conflict rule went the strict way
Two different values for one competitor are now ConflictingCompetitorConfig whether they arrive in one batch or across two. Previously a second add_events silently overwrote a first, last-write-wins, so the same contradictory events errored when batched and succeeded, order-dependently, when fed one at a time. You flagged that as sitting awkwardly beside tests/ingestion_equivalence.rs; it was worse than awkward, it was a direct contradiction of it.
That needed a declared map on History, because a Rating cannot say whether a value was chosen or inherited from the defaults — which is exactly the distinction a conflict check needs.
register also rejects a non-default weight rather than ignoring it. Weight is per-event and meaningless on a registration, and silently dropping a field the caller set is the defect this whole area kept producing.
Two of this issue's four bullets had already dissolved
Worth recording, since the issue argued from them. "Missing it is silent" and "missing it is permanent" both stopped being true at 8c087ad in 0.4.0, which made configuration apply whenever supplied and refit the whole history. Measured on the route this issue names as the worst case:
first seen via record_winner, configured later : mu = 40.000000000
configured from the start : mu = 40.000000000
never configured : mu = 4.027250930
So the framing was already half out of date when I picked it up. What survived was the literal title and the absence of any way to check — both now addressed.
Not done
default_rating_for is #53. It is the better answer for the motivating case, since a rule beats enumerating thousands of layout keys, but it adds a Fn parameter to History and that is a design decision rather than a task. #53 lays out three shapes and says which I would pick and why the choice is not obvious.
Less urgent than it was: the consumer that motivated it has since found the per-hole claim does not clear its sample size regardless (#51), so this is now wanted for its ergonomics rather than to unblock anything.
Closing. The title claim is no longer true, and the closure form is #53.
## What shipped
`History::register(Member)` states configuration before anything is observed:
```rust
h.register(Member::new("layout_7").with_drift_scale(0.0))?;
h.record_winner(&"player", &"layout_7", 1)?; // the route #37 could not reach
assert_eq!(h.rating(&"layout_7").unwrap().drift_scale(), 0.0);
```
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 rather than a promise to apply something later.
`History::rating(&key)` is the read-back you asked for as the cheap diagnostic. 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.
Your four tests are in `tests/registration.rs`, including the one you singled out — a competitor first seen through `record_winner` picking up a pre-registered rating — and order-independence.
## The conflict rule went the strict way
Two different values for one competitor are now `ConflictingCompetitorConfig` whether they arrive in one batch or across two. Previously a second `add_events` silently overwrote a first, last-write-wins, so the same contradictory events **errored when batched and succeeded, order-dependently, when fed one at a time**. You flagged that as sitting awkwardly beside `tests/ingestion_equivalence.rs`; it was worse than awkward, it was a direct contradiction of it.
That needed a `declared` map on `History`, because a `Rating` cannot say whether a value was *chosen* or inherited from the defaults — which is exactly the distinction a conflict check needs.
`register` also rejects a non-default `weight` rather than ignoring it. Weight is per-event and meaningless on a registration, and silently dropping a field the caller set is the defect this whole area kept producing.
## Two of this issue's four bullets had already dissolved
Worth recording, since the issue argued from them. "Missing it is silent" and "missing it is permanent" both stopped being true at `8c087ad` in 0.4.0, which made configuration apply whenever supplied and refit the whole history. Measured on the route this issue names as the worst case:
```
first seen via record_winner, configured later : mu = 40.000000000
configured from the start : mu = 40.000000000
never configured : mu = 4.027250930
```
So the framing was already half out of date when I picked it up. What survived was the literal title and the absence of any way to check — both now addressed.
## Not done
`default_rating_for` is #53. It is the better answer for the motivating case, since a rule beats enumerating thousands of layout keys, but it adds a `Fn` parameter to `History` and that is a design decision rather than a task. #53 lays out three shapes and says which I would pick and why the choice is not obvious.
Less urgent than it was: the consumer that motivated it has since found the per-hole claim does not clear its sample size regardless (#51), so this is now wanted for its ergonomics rather than to unblock anything.
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.
prioranddrift_scaleare captured at a competitor's first appearance (#34). That decision is right — a competitor that is static is static, and a scale that changed between events would make the trajectory uninterpretable. What is missing is a reliable way to comply with it.Today the only way to configure a competitor is to attach the config to whichever event happens to mention them first. There is no way to say it once, up front.
Why the current shape is fragile
The capture is at
src/history.rs:987, and the consume is at:727:That
removeruns only insideif !self.agents.contains(*agent). So:.with_drift_scale(0.0)on a known key is dropped with no error and no warning. Nothing distinguishes "configured" from "configured too late".tests/ingestion_equivalence.rsexists to protect.record_winner/record_draw, or viaEventBuilder, it cannot be set at all — see #37.History::interndoes not help. It touches only theKeyTable(src/history.rs:227-233) and never theCompetitorStore, so interning a key does not create a competitor — which is fortunate, since it means interning does not accidentally poison the capture, but it also means there is no existing pre-registration path to extend.The motivating case
From #34:
ustattreats disc-golf layouts and holes as competitors alongside players, with layouts static so a solo round's evidence lands on the player rather than being split. The correct statement there is a property of the domain — "every layout is static" — not of whichever round happens to be ingested first. Expressing it per-event means every ingestion path has to remember it forever, and forgetting once silently corrupts that competitor's entire trajectory with no diagnostic.Possible shapes
The closure form is the more interesting one: it makes "layouts are static" a single declarative statement that cannot be forgotten on an ingestion path, and it is evaluated exactly where the defaults are applied today (
src/history.rs:727). It does add aFntype parameter toHistory, which is worth weighing against theCopy-and-simple posture the crate otherwise keeps — a boxed closure or a small trait would avoid another generic.Whichever lands, a diagnostic for the silent drop is worth having on its own, and is much cheaper: either return an error when a
prior/drift_scaleis supplied for a competitor that already exists, or expose a query (History::rating(&key)) so callers can assert what they got. Right now there is no way to check from outside the crate.Tests
record_winnerstill picks up a pre-registered rating — the case #37 cannot reach.tests/ingestion_equivalence.rsto cover configured competitors.Closing. The title claim is no longer true, and the closure form is #53.
What shipped
History::register(Member)states configuration before anything is observed:It takes the same
Memberingestion takes, so there is one vocabulary rather than two, and it creates the competitor immediately — which is what makes it observable rather than a promise to apply something later.History::rating(&key)is the read-back you asked for as the cheap diagnostic. 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.Your four tests are in
tests/registration.rs, including the one you singled out — a competitor first seen throughrecord_winnerpicking up a pre-registered rating — and order-independence.The conflict rule went the strict way
Two different values for one competitor are now
ConflictingCompetitorConfigwhether they arrive in one batch or across two. Previously a secondadd_eventssilently overwrote a first, last-write-wins, so the same contradictory events errored when batched and succeeded, order-dependently, when fed one at a time. You flagged that as sitting awkwardly besidetests/ingestion_equivalence.rs; it was worse than awkward, it was a direct contradiction of it.That needed a
declaredmap onHistory, because aRatingcannot say whether a value was chosen or inherited from the defaults — which is exactly the distinction a conflict check needs.registeralso rejects a non-defaultweightrather than ignoring it. Weight is per-event and meaningless on a registration, and silently dropping a field the caller set is the defect this whole area kept producing.Two of this issue's four bullets had already dissolved
Worth recording, since the issue argued from them. "Missing it is silent" and "missing it is permanent" both stopped being true at
8c087adin 0.4.0, which made configuration apply whenever supplied and refit the whole history. Measured on the route this issue names as the worst case:So the framing was already half out of date when I picked it up. What survived was the literal title and the absence of any way to check — both now addressed.
Not done
default_rating_foris #53. It is the better answer for the motivating case, since a rule beats enumerating thousands of layout keys, but it adds aFnparameter toHistoryand that is a design decision rather than a task. #53 lays out three shapes and says which I would pick and why the choice is not obvious.Less urgent than it was: the consumer that motivated it has since found the per-hole claim does not clear its sample size regardless (#51), so this is now wanted for its ergonomics rather than to unblock anything.