Repository navigation
Klondike: higher Draw 1 and Draw 3 win rates from pruning, stock macros and search efficiency - #3
Merged
Conversation
The FoundationRollback rule compared face-up counts before and after returning a card from a foundation, but the returned card itself always adds one face-up card, so the rule never pruned anything. Between 26% and 52% of the nodes expanded in the first search chunk were foundation -> column moves that almost never appear in solutions. Replace it with FoundationRollbackNeedsReceiver, evaluated before apply(): a rollback is only explored when a face-up card in another column, or the current waste card, has rank - 1 and the opposite color; aces never return. Seeded paired runs (seed 1234, 192 games, same node budget): Draw 3 126 -> 129 wins, Draw 1 156 -> 159 wins. Record a 500-game Draw 3 run at 68.60% in docs/results.
Pure StockPileAdvance nodes were 18-37% of the nodes expanded in Draw 3 (up to 56% in Draw 1), each one re-branching the whole tableau for a state that only differed by stock_index. Model the stock the way Klondike-Solver and Solvitaire do: every waste card reachable in the current pass is offered as a single "advance k times and play" move (StockDrawToColumn / StockDrawToFoundation), and recycling is available from any point of the pass (StockDrawRecycle). Plays of the current waste card and a recycle at the end of the stock stay primitive. A macro costs one search node but 1 + k steps of depth, so the depth limit and the IDA* g cost match primitive accounting. Paths are expanded back to primitives before leaving the solver, so the UI and progress tokens are unchanged. Nearer waste cards sort first via PRIORITY_DRAW_COST. Unseeded 500-game runs on top of the rollback fix: Draw 3 68.20% at 6.03 s (was 68.60% at 6.97 s), Draw 1 82.80% at 7.54 s (was 81.60% at 7.94 s). Win rates unchanged within noise, time per game 5-13% lower. Both CSVs recorded in docs/results.
The checkpoint node limits were shared by Draw 1 and Draw 3. The stock macro moves cut the time per game by about 13% in Draw 3 at the same node budget, so split CHECKPOINT_BASE_LIMITS into a Draw 1 table (as before) and a Draw 3 table raised by that proportion (8M -> 9M nodes for 19+ hidden cards), selected by CheckpointPolicy::for_draw. Wall-clock per game returns to the pre-macro level while the search gets the nodes. Drop BoardEval::stock_inaccessible: it was computed on the hot path and never read. Unseeded 500-game Draw 3 run: 69.80% at 6.94 s (macro-moves run: 68.20% at 6.03 s). Draw 1 is unchanged by construction.
Every apply() rehashed the whole board with FNV-1a, and every candidate move recomputed ordering bonuses that scanned all seven columns. Both costs scale with the node count, which is the solver's budget. - Replace the FNV-1a signature with Zobrist hashing: one compile-time key per (column, position, facing, card), per stock card, per waste pointer value and per (suit, rank) foundation state. Board mutations go through helpers that XOR only the keys they touch; compute_signature() remains for boards edited by hand, and apply() debug-asserts that the incremental value matches a full recompute. - Precompute per node which columns bury the next foundation card (OrderingContext), inspect only the source column for the reveal bonus, and evaluate the safe-foundation test once per candidate. - Generate reachable waste positions into a fixed buffer instead of a Vec per node. Search decisions are unchanged: with seed 777, 24 games per draw produce identical moves, undos, checkpoints and outcomes; time per game drops about 19% in Draw 3 and 15% in Draw 1.
Each search chunk runs between 5 and 84 IDA* iterations, and every
iteration re-expanded the whole tree below the bound, including subtrees
that had already failed with a known next bound.
The transposition table now stores TtEntry { budget, fail_h }. When a
search below a state returns a finite NextBound, the state (and every node
of a compressed safe-foundation chain inside it, at its own depth) records
fail_h = next_bound - depth. tt_verdict() then returns Explore, Skip (the
existing budget check) or Bounded(f): the subtree is skipped while
depth + fail_h exceeds the bound, and f is reported upward so the bound
progression is unchanged. NextBound(i64::MAX) is never cached because dead
ends may depend on the path.
The primitive reference search used by the chain-compression tests mirrors
the cache, and a new test checks that a failed subtree is not re-expanded
until the bound reaches its cached value.
Draw 1, 500 games: seeded 1234 82.00% at 9.48 s; unseeded 85.40% at 9.38 s.
The unseeded P5 run committed earlier (81.20% at 6.62 s, same settings)
shows the cost: skipped children are generated and looked up without being
charged as nodes, so wall-clock per game rises ~42% at the same node
budget. Needs a node-limit rebalance or charging Bounded skips before
release. Draw 3 not yet measured with this change.
jsgrrchg
force-pushed
the
zeron/klondike-draw-3-solver-optimization
branch
from
October 12, 2026 23:39
242fd54 to
c3e3c74
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Raises the Klondike win rate in both draw modes without changing the solver's determinism or its FFI surface. The work started from a profile of Draw 3, which was well below published results, and ended up helping Draw 1 too.
main(e7fc850), seed 1234, 192 gamesSix commits, each one self-contained and tested:
How the problem was diagnosed
A temporary per-chunk trace (not committed) over 24 seeded Draw 3 games showed where the node budget went:
FoundationRollbackrule that should limit them never pruned anything (details under P1).stock_index.Changes
P1 — Foundation rollbacks need a visible receiver (5f36e39)
FoundationRollbackcompared the tableau face-up count before and after returning a card from a foundation. The returned card itself always adds one face-up card, so the rule was a no-op and onlyMAX_UNDOSlimited these moves.FoundationRollbackNeedsReceiverreplaces it and runs incan_prune_early, beforeapply(). A rollback is explored only when a visible card of rank − 1 and opposite color could then be placed on it: face-up in another column, or the current waste card. Aces never return. A stricter variant (the receiver must also lack any other destination) gave exactly the same results, so the simpler one was kept. Strict mode is unchanged.Seeded paired runs (seed 1234, 192 games, same node budget):
P2 — Stock macro moves (c0adb90)
The stock is now modelled like Klondike-Solver and the K⁺ representation of Bjarnason et al. (2007): there are no pure advance nodes.
StockDrawToColumn/StockDrawToFoundation { advances, increment, … }offer every waste card reachable in the current pass as one move.StockDrawRecyclerecycles from any point of the pass.1 + advancessteps of depth, soMAX_DEPTHand the IDA*gcost keep primitive accounting.PRIORITY_DRAW_COST(15 per folded advance) explores nearer waste cards first.expand_stock_macros) before leavingsolve_with_stats.apply_move,progress_tokenand the UniFFI interface are unchanged.lib.rsmaps the new variants defensively in case one ever escapes.find_candidate_movesnow returns the macro view;find_primitive_movesis the derived primitive view used by tests as the UI legality oracle.It was first gated to Draw 3 behind a flag, then enabled for Draw 1 after measuring, and the flag was removed.
P4 — Per-draw checkpoint node limits (e9bc8a1)
CHECKPOINT_BASE_LIMITSis split intoCHECKPOINT_BASE_LIMITS_DRAW1(unchanged) andCHECKPOINT_BASE_LIMITS_DRAW3, raised ~13% per bucket (8M → 9M nodes for 19+ hidden cards). This gives back the time P2 saved in Draw 3.CheckpointPolicy::for_drawreplacesdefault_policy. The unusedBoardEval::stock_inaccessiblefield, computed on the hot path and never read, is removed.Draw 3, 500 games, unseeded: 69.80% at 6.94 s (P2 run: 68.20% at 6.03 s).
P5 — Incremental Zobrist signature and cheaper ordering (8c94017)
The node budget is fixed, so faster nodes mean more search in the same time.
push_face_up,pop_face_up,reveal_top_if_hidden,set_foundation_rank,set_stock_index,take_waste_card) that XOR only the keys they touch.apply()debug-asserts that the incremental value equals a full recompute.compute_signature()remains for hand-edited boards.OrderingContextprecomputes once per node which columns bury the next foundation card; the reveal bonus inspects only the source column; the safe-foundation test runs once per candidate.Vecper node.Search decisions are unchanged: with seed 777, 24 games per draw produced identical moves, undos, checkpoints and outcomes, with 19% less time in Draw 3 and 15% less in Draw 1.
These two CSVs carry no header comment; their build is inferred from run order and timing.
P7 — Failed-bound cache in IDA* (c3e3c74)
The transposition table stores
TtEntry { budget, fail_h }. When the search below a state returns a finiteNextBound, the state, and every node of a compressed safe-foundation chain inside it at its own depth, recordsfail_h = next_bound − depth.tt_verdictreturns:Explore: not seen, or seen with less budget and no blocking cached bound.Skip: the existing budget check.Bounded(f): skip the subtree whiledepth + fail_hexceeds the bound, and reportfupward so the bound progression is unchanged.NextBound(i64::MAX)is never cached because dead ends may depend on the path (cycle checks). The primitive reference search insolver_optimizations_tests.rsmirrors the cache, so the chain-compression equivalence test also validatesfail_h.Draw 3 has not been measured with P7 yet.
Open issues before merging
Boundedare still generated, applied and looked up in the table, but only expanded nodes are charged against the budget. Options: chargeBoundedskips as nodes, or lower the node limits until time is back at the P5 level and re-measure. The win-rate gain (81.2% → 82.0–85.4%) is within noise of a 500-game run, so it is not yet clear that P7 pays for its cost.--seed 1234and be paired game by game.Compatibility
solver_core.udlis untouched. Returned paths contain only primitive moves.i64to two. WithTT_MAX_ENTRIES = 5_000_000that is up to ~40 MB more in the worst case; consider lowering it on iOS.solver-core-rswith a streaming API (solve_start/solve_next/solve_cancel) that this repository does not have. None of these changes are ported there yet. When porting P2,solve_next_chunkmust expand stock macros per chunk, from the chunk's start board, before returning moves.Rejected
solve_nextreturns it, so a checkpoint cannot be withdrawn, and a validate-before-deliver variant adds latency and complexity to streaming.Testing
cargo fmt --check,cargo test(111 lib + 21 integration tests) andcargo clippywith no new warnings on touched files, at every commit.Follow-ups
configurationsection of the TUI CSV so seeded runs are self-describing.PRIORITY_DRAW_COST.References