Skip to content

fix(astar_bag): do not hang on graphs with a zero-cost cycle - #838

Open
tachsin wants to merge 1 commit into
evenfurther:mainfrom
tachsin:fix/astar-bag-zero-cycle
Open

tachsin wants to merge 1 commit into
evenfurther:mainfrom
tachsin:fix/astar-bag-zero-cycle

Conversation

@tachsin

@tachsin tachsin commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #837.

astar_bag never returns its first solution when the graph has a cycle whose edges all cost zero. It is not slow, it does not terminate: the iterator allocates until the process dies. A single zero-cost self loop is enough.

let succ = |&n: &u32| match n {
    0 => vec![(1, 1u32)],
    1 => vec![(1, 0), (2, 1)],   // zero-cost self loop
    _ => vec![],
};
astar(&0, succ, |_| 0, |&n| n == 2);      // fine
astar_bag(&0, succ, |_| 0, |&n| n == 2);  // cost 2, then the iterator never yields

Cause

Two things break at once.

A vertex reached again at exactly the cost already recorded gains another optimal parent. Across an edge costing nothing that is reachable, so a vertex becomes an optimal parent of itself, or of a vertex it forms a zero-cost cycle with. complete walks back through parents and never finds the end.

Less obviously, complete decided it had reached the start when a vertex had no parents. A zero-cost cycle through the start gives the start parents of its own, so that test stops firing. This is what the smallest random failure looked like, with 1 -> 0, 0 -> 2, 2 -> 1 all costing zero:

start 1, goal 0, expected [1, 0]

Fix

Stop at the start vertex itself rather than at a vertex without parents, and where a loop is possible, skip parents already on the path being built, backtracking when that leaves a vertex with nowhere to go.

Paths therefore stay simple, which is the position astar already takes: "a node will never be included twice in the path". It is also the only finite answer available, since a zero-cost cycle admits infinitely many equally cheap walks.

The backtracking matters. Skipping a parent can empty the candidate set at a vertex that is not the start, and the old code read an empty set as "reached the start" and emitted the path. My first attempt did exactly that and turned the hang into astar_bag reporting a cost while yielding no solutions at all, which the random comparison below caught.

Keeping the usual case at full speed

Costs are non-negative, so a cycle among optimal parents needs every edge on it to cost nothing. The search records whether any such edge was ever relaxed, and when there was none the original walk runs unchanged.

That separation is worth having. Selecting between the two inside the loop, rather than choosing the loop up front, costs about 17% on path enumeration even when the guarded branch never runs. Enumerating all 3432 shortest paths across an open 7x7 grid, fastest of 20 runs, alternating which build goes first over 6 rounds:

min
main 0.333 ms
this branch 0.345 ms

so +3.6% on a deliberately worst-case workload. For reference, two builds of unmodified main in separate worktrees measured 0.332-0.348 and 0.334-0.335 on the same harness, so the residual is small but real rather than layout noise.

Tests

tests/astar_bag_zero_cost.rs:

  • the zero-cost self loop and the zero-cost cycle, each returning the expected single path
  • zero-cost edges that form no cycle, which were never broken and must stay working
  • 300+ random graphs with zero-cost edges, parallel edges and self loops, checked against brute-force enumeration of all shortest simple paths: same cost, same set of paths, no duplicates
  • 400 random graphs where whatever astar_bag reports must match astar, and a reported cost must come with at least one solution

Against main the first two hang and the random ones hang, so they do detect the bug. On an open 7x7 grid both versions return the same 3432 paths, so nothing changes where there is no zero-cost cycle.

Walking back through optimal parents stopped at a vertex without
parents, taking that to mean the start had been reached. An edge
costing nothing breaks both halves of that.

A vertex reached again at exactly the cost already recorded gains
another optimal parent, so across a zero-cost edge a vertex becomes an
optimal parent of itself, or of a vertex it forms a zero-cost cycle
with. Walking those never ends, and the first solution never arrives:
the iterator allocates until the process dies. A zero-cost cycle
through the start also gives the start parents of its own, so having no
parents no longer identifies it.

Stop at the start vertex itself, and where a loop is possible, skip
parents already on the path being built, backtracking when that leaves
a vertex with nowhere to go. Paths stay simple, which is what astar
already promises.

Costs are non-negative, so a cycle among optimal parents needs every
edge on it to cost nothing. Whether such an edge was ever relaxed is
recorded during the search, and when there was none the original walk
is used unchanged, which keeps the usual case off the slower path.

Fixes evenfurther#837
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.

astar_bag does not terminate on graphs with a zero-cost cycle

1 participant