Skip to content

fix: panic instead of returning a wrapped path cost - #834

Open
tachsin wants to merge 1 commit into
evenfurther:mainfrom
tachsin:fix/cost-overflow
Open

tachsin wants to merge 1 commit into
evenfurther:mainfrom
tachsin:fix/cost-overflow

Conversation

@tachsin

@tachsin tachsin commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #598.

Adding costs can overflow the cost type. Debug builds panic; release builds wrap and return a small, plausible-looking, wrong cost. Three edges of u32::MAX / 2 currently give 2,147,483,645, which is 6442450941 mod 2^32, and nothing in the result says so.

Approach

The blocker recorded in the issue was that checked arithmetic would need num_traits::CheckedAdd or SaturatingAdd on C, which would be breaking. It turns out the existing bounds are enough: C is already Zero + Ord + Copy, num_traits::Zero implies Add<Output = Self>, and costs are required to be non-negative. Adding a non-negative value can never produce a smaller one, so a wrapped sum is detectable by comparison:

let sum = a + b;
if sum < a && b >= C::zero() {
    cost_overflow();
}
sum

No new trait bound, so this can ship in a patch release. Release builds now agree with debug builds rather than returning a wrapped cost.

The panic lives in a separate #[cold] #[inline(never)] function so that add_costs stays small enough to inline.

What is left alone

  • Cost types whose addition saturates never produce a smaller sum, so they never trip the check. The workaround suggested in the issue keeps working, and there is a test for it.
  • Floating point costs reach infinity rather than wrapping, so they compare greater and are untouched.
  • Negative addends are skipped, since they are outside what these algorithms support and the check cannot say anything useful about them.

Covered

astar, astar_bag, astar_bag_collect, dijkstra, dijkstra_bidirectional, dijkstra_all, dijkstra_partial, dijkstra_reach, fringe, idastar, yen. Each gains the # Panics section requested in the issue.

Adding those sections made five #[expect(clippy::missing_panics_doc)] attributes unfulfilled, so they are removed here. Those functions could always panic; the lint had been silenced rather than satisfied. Worth noting that clippy does not catch this class on its own, since missing_panics_doc does not trace through a call into a private helper.

Tests

tests/cost_overflow.rs covers every affected entry point, the heuristic addition specifically (the case in the issue), the saturating-cost-type workaround, and that ordinary costs are unchanged.

Against current main they fail in release, which is where the bug is: 11 of 13 fail, the two that pass being the two meant to behave identically either way. In debug they pass against main too, because debug builds already panicked there, which is the behaviour this PR extends to release. The should_panic expectations therefore match on overflow rather than the exact message, since debug trips the built-in attempt to add with overflow inside + before the library's own check is reached.

Cost

This puts a branch in the innermost loop, so it is not free, but it is cheap.

Instruction counts, comparing main against this branch. These use the corrected benchmarks from #835, because the ones currently on main do not measure the work they run (#833) and gave a badly misleading answer here; see the note below.

benchmark uses costs main this branch change
corner_to_corner_idastar yes 153,194 158,177 +3.3%
no_path_fringe yes 2,907,008 2,936,349 +1.0%
no_path_dijkstra yes 3,033,622 3,054,515 +0.7%
corner_to_corner_dijkstra yes 3,059,458 3,080,348 +0.7%
corner_to_corner_astar yes 152,818 153,796 +0.6%
corner_to_corner_fringe yes 118,850 119,578 +0.6%
no_path_astar yes 3,517,679 3,537,909 +0.6%
corner_to_corner_bfs no (control) 2,636,674 2,636,676 0.0%
no_path_bfs no (control) 2,543,357 2,543,360 0.0%
corner_to_corner_bfs_bidirectional no (control) 2,797,635 2,797,664 0.0%
no_path_bfs_bidirectional no (control) 2,662,484 2,662,523 0.0%
corner_to_corner_iddfs no (control) 3,790,714 3,790,713 0.0%

Every algorithm that adds costs pays between 0.6% and 3.3%. Five of the six that do not add costs are unchanged to the instruction. The sixth, dfs, moves -6.7% despite not being touched, which is codegen drift and the one blemish on an otherwise very clean set.

Wall clock shows no measurable change: on a 256x256 grid, fastest of 40 runs over 5 alternated rounds, dijkstra came out 5.4% faster with the check while bfs, which uses no costs and cannot be affected, came out 5.8% faster in the same run, so that is layout rather than the check.

A correction

An earlier version of this description reported +18.6% on corner_to_corner_dijkstra. That figure was an artefact of #833. With the benchmarks as they stand on main, dijkstra is inlined wholesale into the benchmark harness, and adding a branch to its inner loop upends that inlining, so the comparison measured the harness rather than the change. With the search behind a call the optimiser cannot see through, which is also closer to how callers actually invoke it, the cost is 0.7%. I am sorry for the noise; the number stood for a few hours.

Measurements on Windows 11, Intel Core Ultra 7 265K, 64 GB RAM; instruction counts from WSL2 Ubuntu 24.04 on the same machine with valgrind 3.22.0 and iai-callgrind 0.16.1.

Adding costs could overflow the cost type. Debug builds panicked; release
builds wrapped and returned a small, plausible-looking, wrong cost.

The cost type is only required to be Zero + Ord + Copy, so checked_add is
not available and requiring CheckedAdd would be a breaking change. Costs
are required to be non-negative, though, and that is enough to spot a
wrapped sum without a new bound: adding a non-negative value can never
produce a smaller one, so a sum that compares less than the value it was
added to must have wrapped.

Release builds now agree with debug builds. Cost types whose addition
saturates, and floating point costs which reach infinity rather than
wrapping, compare greater and are left alone, so the documented workaround
of wrapping the cost type keeps working.

Also documents the panic on the affected functions, and drops five
expect(clippy::missing_panics_doc) attributes that those docs make
unnecessary.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

overflow panic in debug estimating cost

1 participant