Skip to content

feat: add astar_reach for steppable A* search - #787

Open
tachsin wants to merge 2 commits into
evenfurther:mainfrom
tachsin:feat/astar-reach
Open

feat: add astar_reach for steppable A* search#787
tachsin wants to merge 2 commits into
evenfurther:mainfrom
tachsin:feat/astar-reach

Conversation

@tachsin

@tachsin tachsin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements astar_reach for #775.

A* currently runs to completion. There is no way to step it, inspect expansions, or stop early. dijkstra_reach, bfs_reach, and dfs_reach already provide that for the other searches.

astar_reach(start, successors, heuristic) yields one AstarReachableItem per expansion:

  • node, parent, total_cost (g) — same idea as DijkstraReachableItem
  • estimated_cost (f = g + h) — so a visualizer can show the A* priority

Nodes come out in the same order astar would expand them. Each node is yielded at most once. There is no goal predicate; stop from the outside with find, take_while, or by dropping the iterator.

idastar / iddfs are not in this PR. They restart the search at each bound, so a useful iterator is a different design.

Closes #775

Test plan

  • cargo test --test astar-reach
  • Zero heuristic matches dijkstra_reach on the same graph
  • Stopping at the goal reports the same cost as astar
  • A consistent heuristic expands fewer nodes than h = 0

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Inconsistent or stateful heuristics can produce incorrect costs and misleading expansion priorities.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a steppable A* iterator for inspecting node expansions and stopping externally.

Changes:

  • Introduces astar_reach and its iterator/item types.
  • Adds coverage for costs, expansion behavior, parents, and fused iteration.
File summaries
File Description
src/directed/astar.rs Implements the A* reachability iterator and public API.
tests/astar-reach.rs Tests iterator behavior and comparison with existing searches.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/directed/astar.rs Outdated
Comment on lines +446 to +447
if !self.seen.insert(index) {
continue;
Comment thread src/directed/astar.rs Outdated
let item;
let successors = {
let (node, &(parent_index, _)) = self.parents.get_index(index).unwrap();
let estimated_cost = total_cost + (self.heuristic)(node);
tachsin and others added 2 commits September 11, 2026 20:45
A* could not be interrupted or inspected mid-search. astar_reach
yields each expansion so callers can step, visualize, or stop early.

Co-authored-by: Cursor <cursoragent@cursor.com>
… cheaper path appears

Two problems raised in review, both of which made the iterator disagree with
`astar` on graphs `astar` handles.

A closed set stopped a node being expanded a second time. `astar` requires only
that the heuristic be admissible, and an admissible heuristic need not be
consistent: when it drops by more than the cost of the edge travelled, a cheaper
route to an already-expanded node turns up later and `astar` expands it again.
Suppressing that does not merely hide the repeat, it reports costs that are
wrong. On the graph from the review, with `S->A` 3, `S->B` 1, `B->A` 1, `A->G` 1
and `h(A)` 0 against `h(B)` 2, the iterator gave the goal a cost of 4 where
`astar` returns 3. A node is now yielded again when a cheaper path to it is
found, which is what `astar` does, and the documentation says so instead of
promising each node once.

`estimated_cost` was recomputed by calling the heuristic again for the node
being expanded, rather than reported from the queue entry that selected it. The
heuristic is an `FnMut` and may be stateful, so the extra call both reports a
value that took no part in the search and perturbs the search being watched. The
popped priority is now carried through. This also fixes the start node, which
was queued with an estimate of zero rather than its own heuristic; nothing read
that before, because expansion recomputed it.

Both are covered by tests that fail against the previous code: the review's
graph, checked against `astar` and asserting the node really is expanded twice,
and a heuristic that answers differently the second time it is asked about a
node.
@tachsin

tachsin commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Both review comments are addressed, and the branch is rebased onto current main.

Re-expansion

The report is right, and the counterexample reproduces exactly: on S->A 3, S->B 1, B->A 1, A->G 1 with h(A) 0 against h(B) 2, the iterator gave the goal a cost of 4 where astar returns 3.

I took the first of the two options offered — support re-expansion — rather than requiring a consistent heuristic, for two reasons. astar itself asks only for admissibility, so requiring more here would make the iterator refuse graphs the function it mirrors accepts. And #775 asks for this in order to watch A* work: when the heuristic is inconsistent, expanding a node a second time is what A* does, so hiding it would misrepresent the algorithm being inspected.

The closed set is gone. A node is yielded again when a cheaper path to it appears, exactly as astar expands it again. The documentation now says that, in place of the "at most once" promise, and notes that a consistent heuristic — which is what most callers write — still produces each node once.

estimated_cost

Also right. It was recomputed by calling the heuristic afresh for the node being expanded, instead of reported from the queue entry that selected it. The popped priority is now carried through, so the heuristic is not called again for an expanded node.

That turned up a third thing on the same line of reasoning: the start node was queued with an estimate of zero rather than its own heuristic. Nothing read it before, because expansion recomputed the value, so fixing the recomputation would have exposed it. The start is now queued with heuristic(start).

Tests

Two, both of which I checked fail against the previous implementation:

  • the graph from the review, asserting the goal's cost matches astar and that A really is expanded twice, at 3 and then at 2
  • a heuristic that answers differently the second time it is asked about a node, so a recomputed f is visible rather than coincidentally equal — it reported 1 for a node whose queued priority was 6

The six existing tests are unchanged and still pass; none of them asserted the "at most once" property, only the documentation did. 300 tests pass overall, clippy and rustfmt clean.

One thing worth your call

Dropping the closed set means AstarReachable no longer promises distinct nodes. For a consistent heuristic nothing changes, but a caller collecting into a Vec and assuming uniqueness would now see repeats on an inconsistent one. That seemed better than silently reporting a wrong cost, but it is a contract change and the alternative — document a consistency requirement and reject the counterexample as out of contract — is still open if you prefer it.

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.

Steppable versions of A* (astar_reach) and of other algorithms

2 participants