Skip to content

feat: let the caller break ties in astar and idastar - #831

Open
tachsin wants to merge 1 commit into
evenfurther:mainfrom
tachsin:feat/tiebreak-support
Open

tachsin wants to merge 1 commit into
evenfurther:mainfrom
tachsin:feat/tiebreak-support

Conversation

@tachsin

@tachsin tachsin commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Closes #681.

You asked in that thread how tie-breaking could be supported without modifying the public API of astar/idastar. It turns out it can be, and mostly already is — this makes it real and documented rather than accidental. No public API changes.

Ties are worth caring about

Expansion counts on a 120x120 grid, with the same optimal path returned every time:

tie-break open grid 20% blocked
current behaviour (decreasing cost from the start) 238 1046
h * (1 + 1/1024) 238 1046
cross-product 238 1051
increasing cost from the start 14399 7016

So @cwoodhayes is right that the choice is significant — up to 60x here. Worth saying plainly, though: the current behaviour is already the one usually recommended, and it beat every alternative I tried. The value of choosing is in the unusual case, not the common one.

astar already lets the caller decide

Every row of that table was produced without touching the library, purely through heuristic. The queue orders by estimated cost and then prefers the larger cost from the start; the caller controls both the cost type's Ord and h, so any preference that is a function of g, h or the node can be folded into the heuristic by giving costs room beneath a single step:

const STEP: u64 = 1024;            // one move, 1023 units of room beneath it
let keep_left = |n: &(u64, u64)| distance(n) + n.0;

A term smaller than a step reorders the queue but can never outweigh a real difference in cost, so the path stays optimal. That is not something a reader would guess, so it is now a # Tie-breaking section on astar with a runnable example.

idastar needed one word

It sorted successors of equal estimated cost with sort_unstable_by_key. The caller's order does survive that today — but only because the sort falls back to insertion sort on short slices, which happens to be stable. Nothing promises it.

-        neighbs.sort_unstable_by_key(|(_, _, c1)| *c1);
+        neighbs.sort_by_key(|(_, _, c1)| *c1);

Now the order successors yields them in is the tie-break by contract. I measured it: 0.011 ms per run either way on a 65x65 grid, because both are insertion sort at these lengths.

Tests

tests/tiebreak.rs: a sub-step term selects between equal-cost paths without changing their cost; the tie-break measurably changes how much of the graph is expanded while the path stays optimal; and reversing the order idastar is given its successors reverses the path it returns.

295 tests pass, clippy and rustfmt clean.

Worth your judgement

This answers the request by documenting a technique rather than adding a parameter. If you would rather have an explicit tie-break argument, that needs either a new function or a breaking change, and I did not want to presume either. @cwoodhayes offered to write a PR — if the documented approach does not cover the case that prompted the issue, it would be useful to hear what does.

Ties matter: on an open 120x120 grid with an exact heuristic, the order chosen
between nodes of equal estimated cost is the difference between expanding 238
nodes and 14399, for the same path. With a fifth of the grid blocked it is 1046
against 7016.

`astar` already lets the caller decide, and needed only to say so. It takes ties
in order of decreasing cost from the start, which is the usual recommendation,
and any other preference can be folded into the heuristic: express costs in
units with room beneath a single step and put the tie-break in that room, where
it can reorder the queue but never outweigh a real difference in cost. The
documentation now shows that, since it is not something a reader would guess.

`idastar` explored successors of equal estimated cost in whatever order an
unstable sort left them. That happens to preserve the caller's order today,
because the sort falls back to insertion sort on short slices, but nothing
promises it. A stable sort makes the caller's order the tie-break by contract
rather than by accident. It costs nothing measurable: 0.011 ms per run either
way on a 65x65 grid, since both sorts are insertion sort at these lengths.

No public API changes.
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.

Add tiebreak support to A* implementation

1 participant