Calling converge() on a History that has had no events added panics rather than returning. It panics in both profiles, with a different message in each — and the release one is an out-of-bounds index rather than a clean arithmetic check.
Found against rev 7742b2b891fc341ef13a20d353b9c951790ebfdd.
Reproduction
usetrueskill_tt::{ConstantDrift,History,NullObserver};typeFit=History<i64,ConstantDrift,NullObserver,String>;#[test]fnconverge_on_an_empty_history(){letmuthistory: Fit=Fit::builder_with_key().score_sigma(5.0).build();// No add_events call at all.
let_=history.converge();}
Observed
debug panicked at src/history.rs:225:22
attempt to subtract with overflow
release panicked at src/history.rs:226:42
index out of bounds: the len is 0 but the index is 18446744073709551615
18446744073709551615 is usize::MAX, which confirms the two are the same fault: with overflow checks on it traps at the subtraction; with them off it wraps and the next line indexes off the end.
self.time_slices.len() - 1 is unguarded. At len() == 0 it underflows.
Note len() == 1 is fine: 1 - 1 == 0 gives an empty range, so only the zero case is affected.
Why this is reachable
It looks like a degenerate case, but it arrives from ordinary data. In ustat the per-layout and per-hole slices each get their own History, and events are only fed to the model when there are ≥2 competitors to compare. A disc golf layout that has only ever been played solo therefore produces a slice with zero rated events — a real row in a real CSV, not a contrived input — and converge() brought the whole boot down.
It is easy to guard at the call site once you know (ustat now does), but the failure gives no hint about the cause: neither message mentions histories, events or time slices, and the two profiles disagree about what went wrong.
Suggested fix
Return early from iteration when there is nothing to iterate:
ifself.time_slices.len()<2{return(0.0,0.0);}
which makes converge() on an empty history a no-op reporting converged: true after 0 iterations — arguably the correct answer, since a model with no observations has trivially converged.
If you would rather callers be told explicitly, InferenceError already exists and converge() already returns Result, so an InferenceError::NoEvents variant would work too. Either is better than the current behaviour; the important part is that it should not be an arithmetic fault.
Worth a regression test either way, since the two profiles fail differently and a debug-only test would not have caught the release path.
Calling `converge()` on a `History` that has had no events added panics rather than returning. It panics in **both** profiles, with a different message in each — and the release one is an out-of-bounds index rather than a clean arithmetic check.
Found against rev `7742b2b891fc341ef13a20d353b9c951790ebfdd`.
## Reproduction
```rust
use trueskill_tt::{ConstantDrift, History, NullObserver};
type Fit = History<i64, ConstantDrift, NullObserver, String>;
#[test]
fn converge_on_an_empty_history() {
let mut history: Fit = Fit::builder_with_key().score_sigma(5.0).build();
// No add_events call at all.
let _ = history.converge();
}
```
## Observed
```
debug panicked at src/history.rs:225:22
attempt to subtract with overflow
release panicked at src/history.rs:226:42
index out of bounds: the len is 0 but the index is 18446744073709551615
```
`18446744073709551615` is `usize::MAX`, which confirms the two are the same fault: with overflow checks on it traps at the subtraction; with them off it wraps and the next line indexes off the end.
## Root cause
`History::iteration`, `src/history.rs:220`:
```rust
fn iteration(&mut self) -> (f64, f64) {
let mut step = (0.0, 0.0);
competitor::clean(self.agents.values_mut(), false);
for j in (0..self.time_slices.len() - 1).rev() { // <- 225
for agent in self.time_slices[j + 1].skills.keys() { // <- 226
```
`self.time_slices.len() - 1` is unguarded. At `len() == 0` it underflows.
Note `len() == 1` is fine: `1 - 1 == 0` gives an empty range, so only the zero case is affected.
## Why this is reachable
It looks like a degenerate case, but it arrives from ordinary data. In [ustat](https://git.aceofba.se/logaritmisk/ustat) the per-layout and per-hole slices each get their own `History`, and events are only fed to the model when there are ≥2 competitors to compare. A disc golf layout that has only ever been played solo therefore produces a slice with zero rated events — a real row in a real CSV, not a contrived input — and `converge()` brought the whole boot down.
It is easy to guard at the call site once you know (ustat now does), but the failure gives no hint about the cause: neither message mentions histories, events or time slices, and the two profiles disagree about what went wrong.
## Suggested fix
Return early from `iteration` when there is nothing to iterate:
```rust
if self.time_slices.len() < 2 {
return (0.0, 0.0);
}
```
which makes `converge()` on an empty history a no-op reporting `converged: true` after 0 iterations — arguably the correct answer, since a model with no observations has trivially converged.
If you would rather callers be told explicitly, `InferenceError` already exists and `converge()` already returns `Result`, so an `InferenceError::NoEvents` variant would work too. Either is better than the current behaviour; the important part is that it should not be an arithmetic fault.
Worth a regression test either way, since the two profiles fail differently and a debug-only test would not have caught the release path.
Already fixed — but nothing pinned it, so it does now (eeb43e3).
The guard landed in f4e2922, and git merge-base --is-ancestor 7742b2b f4e2922 confirms the rev you filed against predates it. History::converge returns early on an empty time_slices, and iteration has its own is_empty guard behind that.
Added your exact reproduction as converge_on_an_empty_history_with_owned_keys in tests/degenerate_inputs.rs — the owned-key instantiation, not just the History::default() one that was already there.
Mutation-proved, because a green test proves nothing on its own. Removing both guards reproduces your report verbatim:
debug panicked at src/history.rs:248:22
attempt to subtract with overflow
release panicked at src/history.rs:249:42
index out of bounds: the len is 0 but the index is 18446744073709551615
Worth recording that the first mutation I tried — removing only iteration's guard — left the test green, because converge's early return means iteration is never reached for an empty history. iteration's guard is belt-and-braces; converge's is the load-bearing one. A regression test that only pinned the former would have been vacuous.
Your point about profiles was right and is why the test lives in tests/degenerate_inputs.rs, whose CI job runs in release too — the release failure is an out-of-bounds index, which no debug_assert would have caught.
Already fixed — but nothing pinned it, so it does now (`eeb43e3`).
The guard landed in `f4e2922`, and `git merge-base --is-ancestor 7742b2b f4e2922` confirms the rev you filed against predates it. `History::converge` returns early on an empty `time_slices`, and `iteration` has its own `is_empty` guard behind that.
Added your exact reproduction as `converge_on_an_empty_history_with_owned_keys` in `tests/degenerate_inputs.rs` — the owned-key instantiation, not just the `History::default()` one that was already there.
**Mutation-proved, because a green test proves nothing on its own.** Removing *both* guards reproduces your report verbatim:
```
debug panicked at src/history.rs:248:22
attempt to subtract with overflow
release panicked at src/history.rs:249:42
index out of bounds: the len is 0 but the index is 18446744073709551615
```
Worth recording that the first mutation I tried — removing only `iteration`'s guard — left the test green, because `converge`'s early return means `iteration` is never reached for an empty history. `iteration`'s guard is belt-and-braces; `converge`'s is the load-bearing one. A regression test that only pinned the former would have been vacuous.
Your point about profiles was right and is why the test lives in `tests/degenerate_inputs.rs`, whose CI job runs in release too — the release failure is an out-of-bounds index, which no `debug_assert` would have caught.
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.
Calling
converge()on aHistorythat has had no events added panics rather than returning. It panics in both profiles, with a different message in each — and the release one is an out-of-bounds index rather than a clean arithmetic check.Found against rev
7742b2b891fc341ef13a20d353b9c951790ebfdd.Reproduction
Observed
18446744073709551615isusize::MAX, which confirms the two are the same fault: with overflow checks on it traps at the subtraction; with them off it wraps and the next line indexes off the end.Root cause
History::iteration,src/history.rs:220:self.time_slices.len() - 1is unguarded. Atlen() == 0it underflows.Note
len() == 1is fine:1 - 1 == 0gives an empty range, so only the zero case is affected.Why this is reachable
It looks like a degenerate case, but it arrives from ordinary data. In ustat the per-layout and per-hole slices each get their own
History, and events are only fed to the model when there are ≥2 competitors to compare. A disc golf layout that has only ever been played solo therefore produces a slice with zero rated events — a real row in a real CSV, not a contrived input — andconverge()brought the whole boot down.It is easy to guard at the call site once you know (ustat now does), but the failure gives no hint about the cause: neither message mentions histories, events or time slices, and the two profiles disagree about what went wrong.
Suggested fix
Return early from
iterationwhen there is nothing to iterate:which makes
converge()on an empty history a no-op reportingconverged: trueafter 0 iterations — arguably the correct answer, since a model with no observations has trivially converged.If you would rather callers be told explicitly,
InferenceErroralready exists andconverge()already returnsResult, so anInferenceError::NoEventsvariant would work too. Either is better than the current behaviour; the important part is that it should not be an arithmetic fault.Worth a regression test either way, since the two profiles fail differently and a debug-only test would not have caught the release path.
Already fixed — but nothing pinned it, so it does now (
eeb43e3).The guard landed in
f4e2922, andgit merge-base --is-ancestor 7742b2b f4e2922confirms the rev you filed against predates it.History::convergereturns early on an emptytime_slices, anditerationhas its ownis_emptyguard behind that.Added your exact reproduction as
converge_on_an_empty_history_with_owned_keysintests/degenerate_inputs.rs— the owned-key instantiation, not just theHistory::default()one that was already there.Mutation-proved, because a green test proves nothing on its own. Removing both guards reproduces your report verbatim:
Worth recording that the first mutation I tried — removing only
iteration's guard — left the test green, becauseconverge's early return meansiterationis never reached for an empty history.iteration's guard is belt-and-braces;converge's is the load-bearing one. A regression test that only pinned the former would have been vacuous.Your point about profiles was right and is why the test lives in
tests/degenerate_inputs.rs, whose CI job runs in release too — the release failure is an out-of-bounds index, which nodebug_assertwould have caught.