History::converge() panics on an empty history (usize underflow) #11

Closed
opened 2026-08-04 19:15:17 +00:00 by logaritmisk · 1 comment
Owner
let mut h = History::default();
h.converge().unwrap();
thread panicked at src/history.rs:226:42:
index out of bounds: the len is 0 but the index is 18446744073709551615

Cause

History::iteration opens with (src/history.rs:225):

for j in (0..self.time_slices.len() - 1).rev() {

With no slices, len() - 1 underflows to usize::MAX. In release the range is astronomically large and the first self.time_slices[j + 1] indexes far out of bounds; in debug the subtraction itself panics with an overflow message. Either way, converging a history that has had no events added is a hard crash.

Why it matters

History::converge returns Result<ConvergenceReport, InferenceError> — a caller reasonably assumes bad states come back as Err, not as a panic. An empty history is a completely ordinary state: it is what History::default() gives you, and it is what a caller has after ingesting an empty event stream (add_events returns Ok(()) early for an empty iterator, src/history.rs:689, so "ingest then converge" panics if the input source happened to be empty).

Fix

Guard the sweep: with 0 slices there is nothing to converge, so return a zero-iteration ConvergenceReport with converged: true (or an explicit error, if converging nothing should be a caller mistake — but Ok matches the early-return already in add_events).

Use a saturating/checked form of the bound rather than len() - 1, and check the 1-slice path too: the existing if self.time_slices.len() == 1 branch (src/history.rs:261) suggests the loops are already known not to handle small inputs uniformly.

Acceptance

  • History::default().converge() returns Ok with iterations: 0 and does not panic.
  • h.add_events(std::iter::empty()).and_then(|_| h.converge()) likewise.
  • Same for log_evidence() and learning_curves() on an empty history — audit them for the same pattern.
```rust let mut h = History::default(); h.converge().unwrap(); ``` ``` thread panicked at src/history.rs:226:42: index out of bounds: the len is 0 but the index is 18446744073709551615 ``` ## Cause `History::iteration` opens with (`src/history.rs:225`): ```rust for j in (0..self.time_slices.len() - 1).rev() { ``` With no slices, `len() - 1` underflows to `usize::MAX`. In release the range is astronomically large and the first `self.time_slices[j + 1]` indexes far out of bounds; in debug the subtraction itself panics with an overflow message. Either way, converging a history that has had no events added is a hard crash. ## Why it matters `History::converge` returns `Result<ConvergenceReport, InferenceError>` — a caller reasonably assumes bad states come back as `Err`, not as a panic. An empty history is a completely ordinary state: it is what `History::default()` gives you, and it is what a caller has after ingesting an empty event stream (`add_events` returns `Ok(())` early for an empty iterator, `src/history.rs:689`, so "ingest then converge" panics if the input source happened to be empty). ## Fix Guard the sweep: with 0 slices there is nothing to converge, so return a zero-iteration `ConvergenceReport` with `converged: true` (or an explicit error, if converging nothing should be a caller mistake — but `Ok` matches the early-return already in `add_events`). Use a saturating/checked form of the bound rather than `len() - 1`, and check the 1-slice path too: the existing `if self.time_slices.len() == 1` branch (`src/history.rs:261`) suggests the loops are already known not to handle small inputs uniformly. ## Acceptance - `History::default().converge()` returns `Ok` with `iterations: 0` and does not panic. - `h.add_events(std::iter::empty()).and_then(|_| h.converge())` likewise. - Same for `log_evidence()` and `learning_curves()` on an empty history — audit them for the same pattern.
Author
Owner

Fixed in f4e2922. History::iteration returns early when there are no slices, and converge() short-circuits to a zero-iteration converged: true report.

log_evidence(), learning_curves(), learning_curve() and current_skill() were audited for the same pattern — all iterate rather than index, so none had it. Covered by empty_history_converges_trivially, empty_event_stream_then_converge and empty_history_queries_do_not_panic in tests/degenerate_inputs.rs.

Fixed in f4e2922. `History::iteration` returns early when there are no slices, and `converge()` short-circuits to a zero-iteration `converged: true` report. `log_evidence()`, `learning_curves()`, `learning_curve()` and `current_skill()` were audited for the same pattern — all iterate rather than index, so none had it. Covered by `empty_history_converges_trivially`, `empty_event_stream_then_converge` and `empty_history_queries_do_not_panic` in `tests/degenerate_inputs.rs`.
logaritmisk added the bug label 2026-09-07 13:53:10 +00:00
Sign in to join this conversation.