Skip to content

Klondike: higher Draw 1 and Draw 3 win rates from pruning, stock macros and search efficiency - #3

Merged
jsgrrchg merged 6 commits into
mainfrom
zeron/klondike-draw-3-solver-optimization
Oct 13, 2026
Merged

jsgrrchg merged 6 commits into
mainfrom
zeron/klondike-draw-3-solver-optimization

Conversation

@jsgrrchg

@jsgrrchg jsgrrchg commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

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.

Draw 3 Draw 1
main (e7fc850), seed 1234, 192 games 65.62% 81.25%
This branch, best 500-game run 70.00% (P5 build) 82.00% seeded / 85.40% unseeded (P7 build)
Thoughtful-Klondike ceiling (Solvitaire) 81.945% ± 0.084% ≈ 90.4%

Six commits, each one self-contained and tested:

Commit Change Effect
5f36e39 P1 Real foundation-rollback pruning +3 wins in both draws (seeded, 192 games)
c0adb90 P2 Stock macro moves ("advance k times and play") Win rate flat, 5–13% less time
e9bc8a1 P4 Per-draw checkpoint node limits Draw 3: the P2 time saving reinvested in nodes
8c94017 P5 Incremental Zobrist signature, cheaper move ordering Identical results, 15–19% less time
5d1337e Benchmark CSVs for the P5 build
c3e3c74 P7 Failed-bound cache in IDA* Draw 1 up; time +42%, see open issues

How the problem was diagnosed

A temporary per-chunk trace (not committed) over 24 seeded Draw 3 games showed where the node budget went:

  • 26–52% of first-chunk nodes were foundation → column moves, yet solutions averaged 0.17 undos. The FoundationRollback rule that should limit them never pruned anything (details under P1).
  • 18–37% of nodes were pure stock advances in Draw 3, up to 56% in Draw 1. Each one re-branched the whole tableau for a state that only differed by stock_index.
  • IDA re-expanded the full tree 5–84 times per chunk*, with the bound rising by one almost every iteration.
  • About 5 of 25 Draw 3 losses were genuinely unwinnable deals (identical result in Strict mode); the rest were losses on probably-winnable deals.

Changes

P1 — Foundation rollbacks need a visible receiver (5f36e39)

FoundationRollback compared 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 only MAX_UNDOS limited these moves.

FoundationRollbackNeedsReceiver replaces it and runs in can_prune_early, before apply(). 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):

Before After Flips
Draw 3 126 129 9 won, 6 lost
Draw 1 156 159 4 won, 1 lost

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.
  • StockDrawRecycle recycles from any point of the pass.
  • 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 + advances steps of depth, so MAX_DEPTH and the IDA* g cost keep primitive accounting.
  • PRIORITY_DRAW_COST (15 per folded advance) explores nearer waste cards first.
  • Paths are expanded back to primitives (expand_stock_macros) before leaving solve_with_stats. apply_move, progress_token and the UniFFI interface are unchanged. lib.rs maps the new variants defensively in case one ever escapes.
  • find_candidate_moves now returns the macro view; find_primitive_moves is 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.

500 games, unseeded Win rate Avg time
Draw 3, P1 only 68.60% 6.97 s
Draw 3, P1 + P2 68.20% 6.03 s
Draw 1, before P1 (6 Oct) 81.60% 7.94 s
Draw 1, P1 + P2 82.80% 7.54 s

P4 — Per-draw checkpoint node limits (e9bc8a1)

CHECKPOINT_BASE_LIMITS is split into CHECKPOINT_BASE_LIMITS_DRAW1 (unchanged) and CHECKPOINT_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_draw replaces default_policy. The unused BoardEval::stock_inaccessible field, 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.

  • Zobrist hashing replaces FNV-1a. Keys are generated at compile time from a fixed-seed SplitMix64, so hashes are identical on every platform. Board mutations go through helpers (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.
  • Move ordering: OrderingContext precomputes 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.
  • Reachable waste positions use a fixed buffer instead of a Vec per 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.

500 games, unseeded (5d1337e) Win rate Avg time Stalled avg Won avg
Draw 3 70.00% 5.70 s 7.21 s 5.05 s
Draw 1 81.20% 6.62 s 8.18 s 6.26 s

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 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 returns:

  • Explore: not seen, or seen with less budget and no blocking cached bound.
  • Skip: the existing budget check.
  • Bounded(f): skip the subtree while depth + fail_h exceeds the bound, and report f upward 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 in solver_optimizations_tests.rs mirrors the cache, so the chain-compression equivalence test also validates fail_h.

Draw 1, 500 games Win rate Avg time Stalled avg
P5 build, unseeded 81.20% 6.62 s 8.18 s
P7 build, seeded 1234 82.00% 9.48 s 12.92 s
P7 build, unseeded 85.40% 9.38 s 12.61 s

Draw 3 has not been measured with P7 yet.

Open issues before merging

  • P7 raises wall-clock time by ~42% in Draw 1 at the same node budget. Likely cause: children skipped through Bounded are still generated, applied and looked up in the table, but only expanded nodes are charged against the budget. Options: charge Bounded skips 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.
  • Measure Draw 3 with P7, seeded, against the P5 run.
  • Most results above are unseeded. From now on all comparisons should use --seed 1234 and be paired game by game.

Compatibility

  • Determinism: every change depends only on the board and node counters, never on the clock.
  • FFI: solver_core.udl is untouched. Returned paths contain only primitive moves.
  • Signatures: hash values changed with Zobrist. They are internal to the search and are not persisted.
  • Memory: transposition-table entries grow from one i64 to two. With TT_MAX_ENTRIES = 5_000_000 that is up to ~40 MB more in the worst case; consider lowering it on iOS.
  • SolitaireSolver uses its own fork of solver-core-rs with 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_chunk must expand stock macros per chunk, from the chunk's start board, before returning moves.

Rejected

  • P3, checkpoint candidates with backtracking. Keeping the top three progress candidates and falling back when the adopted one dies cheaply would recover losses where the checkpoint was a dead end. SolitaireSolver animates each checkpoint as soon as solve_next returns 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) and cargo clippy with no new warnings on touched files, at every commit.
  • New tests cover: rollback pruning with and without a receiver; macro generation, legality and exact equivalence with primitive steps; macro expansion and replay; depth accounting for folded advances; per-draw node limits; incremental signature sync for every mutation helper; and failed-bound skipping and re-exploration.
  • The slow ignored smoke tests were not run as part of this branch.

Follow-ups

  • Resolve the P7 time cost, then reinvest the P5 saving into the node limits (+15% Draw 1, +19% Draw 3).
  • Write the seed into the configuration section of the TUI CSV so seeded runs are self-describing.
  • Update the README win-rate figures once Draw 3 with P7 is measured.
  • Remaining ideas: dead-end detection by relaxation (Bjarnason et al. prove 8.56% of deals unwinnable in linear time), transposition entries that dominate across undo counts, canonical column order in the signature, and tuning PRIORITY_DRAW_COST.

References

  • C. Blake, I. P. Gent, The Winnability of Klondike Solitaire and Many Other Patience Games (Solvitaire), arXiv 1906.12314 / JAIR 2024. Draw 3: 81.945% ± 0.084%. Draw 1, Table 8: 904,226 winnable of 10⁶.
  • R. Bjarnason, P. Tadepalli, A. Fern, Searching Solitaire in Real Time, ICGA Journal 30(3), 2007. Draw 3 thoughtful: 72.3% under 4 s, 78.3% under 16 s, 82.2% unbounded (2007 hardware).
  • X. Yan, P. Diaconis, P. Rusmevichientong, B. Van Roy, Solitaire: Man Versus Machine, NIPS 2004. 70.2% with five nested rollouts at 1 h 45 min per game.

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
jsgrrchg force-pushed the zeron/klondike-draw-3-solver-optimization branch from 242fd54 to c3e3c74 Compare October 12, 2026 23:39
@jsgrrchg
jsgrrchg merged commit 64f4c9a into main Oct 13, 2026
14 checks passed
@jsgrrchg
jsgrrchg deleted the zeron/klondike-draw-3-solver-optimization branch October 13, 2026 01:04
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