Skip to content

feat(search): A* and best-first search, with hash-lookup perf work#8

Merged
benmandrew merged 5 commits into
mainfrom
feat/astar-search
Jun 13, 2026
Merged

feat(search): A* and best-first search, with hash-lookup perf work#8
benmandrew merged 5 commits into
mainfrom
feat/astar-search

Conversation

@benmandrew

Copy link
Copy Markdown
Owner

Summary

Adds two new search algorithms beyond the existing BFS/DFS, plus the deduplication-path performance work and a profiling guide that motivated it.

Search algorithms (--algorithm bfs|bestfirst|astar)

  • best-first — greedy, ordered by state_heuristic(n) (foundation distance + hidden-card penalty + blocked-card penalty + empty-column bonus). Explores promising branches first within a fixed budget.
  • astar — ordered by f = g + h, where g is node depth and h is foundation_heuristic (admissible: cards not yet on foundations). Finds shorter winning paths first with a provable lower bound.

Both still build the complete node graph the visualiser consumes; no pruning. A shared expand_node_astar helper avoids duplicating expansion logic.

Deduplication performance

  • Cache the table hash in Node — node tables are immutable after construction, so the hash is computed once instead of being recomputed on every set comparison.
  • Replace the hash-ordered std::set with std::unordered_set (NodeHash + NodeEq). The previous set was ordered solely by Table::hash() with no equality fallback, so any hash collision silently dropped a distinct state. The new container is O(1) average lookup and resolves collisions correctly via Table::operator==. is_transparent enables heterogeneous lookup with a bare Table.

Docs

  • PLAN.md — design space for further search work (A*, Zobrist hashing, POMDPs).
  • PROFILING.md — how to find hot codepaths (RelWithDebInfo build, deterministic workload, Instruments/sample on macOS, perf/callgrind on Linux).

Testing

All 230 assertions pass. Node counts unchanged across BFS/A* at fixed depth, confirming the dedup refactor didn't alter graph contents.

Notes

Profiling on this branch identified JSON serialisation as the dominant wall-clock cost; that work is isolated in a separate PR (#7) off main. The two PRs are independent (no file overlap) and can merge in either order.

Adds two new search algorithms selectable via --algorithm <bfs|bestfirst|astar>:

- bestfirst: greedy best-first ordered by state_heuristic(n), which combines
  foundation distance, hidden-card penalty, blocked-card penalty, and an
  empty-column bonus. Explores promising branches first within a fixed budget.

- astar: A* ordered by f = g + h where g is node depth and h is
  foundation_heuristic (admissible: cards not yet on foundation). Finds
  shorter winning paths first with a provable lower bound.

Both still build the complete node graph required by the visualiser; no
nodes are pruned. The shared expand_node_astar helper avoids duplicating
the expansion logic between the two methods.

PLAN.md documents the design space including Zobrist hashing and POMDPs.
Node tables are immutable after construction, so the hash can be computed
once at construction rather than recomputed on every NodeComparator call.
std::set lookups previously re-ran the full ~72-byte hash_combine on the
node side of every comparison (~log n per find/insert).
m_seen_nodes (and the iterator's visited set) was a std::set ordered solely
by Table::hash(), which had two problems:

- O(log n) lookups, each running multiple hash comparisons.
- Equality was defined by hash alone with no fallback, so any hash collision
  silently dropped a genuinely distinct state.

Switch to std::unordered_set with NodeHash (reads the cached m_hash) and NodeEq
(falls back to full Table::operator==). Lookups are now O(1) average and
collisions are resolved correctly. is_transparent enables heterogeneous lookup
with a bare Table, avoiding a temporary Node on the find path.
Documents how to build an optimised profiling binary (RelWithDebInfo, not the
default Debug), choose a deterministic workload, and use the platform-native
tools (Instruments/sample on Apple Silicon, perf/callgrind on Linux). Calls
out that a still-hot Table::hash in an optimised profile is the empirical
trigger for the Zobrist work in PLAN.md.
PROFILING.md instructs creating a separate RelWithDebInfo build dir at
build-prof/; ignore it like the default build/ dir.
@benmandrew benmandrew merged commit d5b44ac into main Jun 13, 2026
6 checks passed
@benmandrew

Copy link
Copy Markdown
Owner Author

Fixes #3.

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.

1 participant