Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
512 changes: 512 additions & 0 deletions docs/results/KlondikeDraw1_results_20261006_014657.csv

Large diffs are not rendered by default.

512 changes: 512 additions & 0 deletions docs/results/KlondikeDraw1_results_20261006_020927.csv

Large diffs are not rendered by default.

512 changes: 512 additions & 0 deletions docs/results/KlondikeDraw1_results_20261006_023146.csv

Large diffs are not rendered by default.

512 changes: 512 additions & 0 deletions docs/results/KlondikeDraw1_results_20261006_131859.csv

Large diffs are not rendered by default.

512 changes: 512 additions & 0 deletions docs/results/KlondikeDraw1_results_20261006_143625.csv

Large diffs are not rendered by default.

513 changes: 513 additions & 0 deletions docs/results/KlondikeDraw1_results_20261006_152709.csv

Large diffs are not rendered by default.

137 changes: 120 additions & 17 deletions solver-core-rs/src/klondike/board_eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub struct BoardEval {
pub depth_penalty: i64, // sum of fd + (fd-1)/DIVISOR per column
pub stock_advance_cost: i64, // minimum advances needed to access the stock
pub target_burial_penalty: i64, // penalty for face-down target cards
pub deadlock_count: i64, // number of detected logical deadlocks
pub deadlock_count: i64, // same-suit hidden dependency severity, not proven deadlocks
pub reveal_bonus: i64, // bonus for exposing a key card
pub stranded_blockers: i64, // blockers above targets with no destination
pub stock_target_penalty: i64, // penalty for targets trapped in stock
Expand Down Expand Up @@ -148,9 +148,9 @@ impl BoardEval {
continue;
}

// For deadlock detection: the lowest value seen for each suit,
// iterating from surface to depth.
let mut lowest_ranks = [255i32; 4];
// Track the highest hidden rank above each card, per suit,
// while iterating from surface to depth.
let mut highest_ranks = [0u8; 4];

for i in (0..c.face_down_len).rev() {
let card = c.cards[i as usize];
Expand All @@ -163,14 +163,14 @@ impl BoardEval {
eval.target_burial_penalty += obstacle_count * BURIED_TARGET_PENALTY;
}

// Deadlock detection: rank inversion in the same suit.
if lowest_ranks[card_suit] < card_val as i32 {
let severity = (card_val as i32 - lowest_ranks[card_suit]).min(3) as i64;
eval.deadlock_count += severity;
}
if (card_val as i32) < lowest_ranks[card_suit] {
lowest_ranks[card_suit] = card_val as i32;
// A higher same-suit card above a lower one must move out of
// the way before the lower card can reach its foundation.
// This is a progress penalty, not proof of an unsolvable state.
let blocker_rank = highest_ranks[card_suit];
if blocker_rank > card_val {
eval.deadlock_count += (blocker_rank - card_val).min(3) as i64;
}
highest_ranks[card_suit] = blocker_rank.max(card_val);
}

// Reveal value reward (thoughtful: the face-down card is known).
Expand Down Expand Up @@ -325,7 +325,7 @@ impl BoardEval {

/// Progress score for checkpoints (higher is better).
/// Incorporates thoughtful signals: the solver penalizes checkpoints that
/// keep targets buried or deadlocks active.
/// keep targets buried or same-suit dependencies blocked.
pub fn progress_score(&self) -> i64 {
let mut score = self.foundation_count * PROGRESS_FOUNDATION_WEIGHT
+ self.face_up * PROGRESS_FACE_UP_WEIGHT
Expand All @@ -339,10 +339,9 @@ impl BoardEval {
- self.stranded_blockers * PROGRESS_STRANDED_BLOCKER_PENALTY
- self.stock_target_penalty;

// Near-autoplay bonus: states with few hidden cards are close to
// auto-play (hidden==0 -> essentially won).
// Quadratic bonus that grows quickly as hidden approaches 0.
if self.hidden > 0 && self.hidden <= AUTOPLAY_PROXIMITY_THRESHOLD {
// Reward uncovering cards all the way to zero hidden cards. Remaining
// stock can still prevent a win, so this only influences progress.
if (0..=AUTOPLAY_PROXIMITY_THRESHOLD).contains(&self.hidden) {
let proximity = (AUTOPLAY_PROXIMITY_THRESHOLD + 1 - self.hidden) as i64;
score += proximity * proximity * PROGRESS_AUTOPLAY_PROXIMITY;
}
Expand Down Expand Up @@ -454,6 +453,8 @@ impl CheckpointPolicy {
mod tests {
use super::*;
use crate::common::card::Suit;
use crate::klondike::moves::KlondikeMove;
use crate::klondike::solver::KlondikeSolver;

fn card(suit: Suit, value: u8) -> Card {
Card::new(suit, value)
Expand Down Expand Up @@ -570,12 +571,114 @@ mod tests {
let eval = BoardEval::from_board(&board, 3);

assert!(eval.target_burial_penalty > 0);
assert!(eval.deadlock_count > 0);
assert_eq!(eval.deadlock_count, 0);
assert_eq!(eval.reveal_bonus, REVEALED_FOUNDATION_CARD_BONUS);
assert!(eval.stranded_blockers > 0);
assert!(eval.stock_target_penalty >= STOCK_MISALIGNED_TARGET_PENALTY);
}

#[test]
fn revealing_last_hidden_card_improves_adoptable_progress_with_stock_remaining() {
let mut board = KlondikeBoard::new();
board.foundation = [4; 4];
board.columns[0].push(card(Suit::Club, 6), false);
board.columns[1].push(card(Suit::Spade, 9), true);
board.columns[1].push(card(Suit::Heart, 5), false);
for c in [
card(Suit::Club, 13),
card(Suit::Heart, 12),
card(Suit::Club, 11),
card(Suit::Heart, 10),
] {
board.columns[2].push(c, false);
}
for c in [
card(Suit::Spade, 13),
card(Suit::Diamond, 12),
card(Suit::Spade, 11),
] {
board.columns[3].push(c, false);
}
board.columns[4].push(card(Suit::Diamond, 13), false);
board.columns[4].push(card(Suit::Spade, 12), false);

// Complete a valid 52-card position with the remaining 24 cards in stock.
let stock: Vec<_> = Card::standard_deck()
.into_iter()
.filter(|c| {
c.value > 4
&& !board
.columns
.iter()
.any(|column| column.cards[..column.len as usize].contains(c))
})
.collect();
assert_eq!(stock.len(), 24);
set_stock(&mut board, &stock, 0);

let reveal = KlondikeMove::ColumnToColumn {
source: 1,
destination: 0,
count: 1,
};
for draw in [1, 3] {
assert!(KlondikeMove::find_candidate_moves(&board, draw).contains(&reveal));
let after = reveal.apply(board).expect("legal reveal move");
let before_eval = BoardEval::from_board(&board, draw);
let after_eval = BoardEval::from_board(&after, draw);

assert_eq!(before_eval.hidden, 1);
assert_eq!(after_eval.hidden, 0);
assert_eq!(after.stock_len, 24);
assert!(!KlondikeSolver::is_win(&after));
assert!(after_eval.progress_score() > before_eval.progress_score());
assert!(CheckpointPolicy::default_policy().is_adoptable(
before_eval.progress_score(),
after_eval.progress_score(),
board.total_foundation_count(),
));
}
}

#[test]
fn hidden_dependencies_penalize_lower_cards_below_higher_cards_of_the_same_suit() {
for (buried, above, expected_severity) in [
(card(Suit::Club, 1), card(Suit::Club, 4), 3),
(card(Suit::Club, 4), card(Suit::Club, 1), 0),
(card(Suit::Club, 1), card(Suit::Club, 2), 1),
(card(Suit::Club, 1), card(Suit::Club, 13), 3),
(card(Suit::Club, 1), card(Suit::Spade, 4), 0),
] {
let mut board = KlondikeBoard::new();
board.columns[2].push(buried, true);
board.columns[2].push(above, true);
board.columns[2].push(card(Suit::Heart, 9), false);
board.compute_signature();

for draw in [1, 3] {
assert_eq!(
BoardEval::from_board(&board, draw).deadlock_count,
expected_severity,
"hidden cards from base to surface: {buried:?}, {above:?}",
);
}
}
}

#[test]
fn hidden_dependency_tracking_is_local_to_each_column() {
let mut board = KlondikeBoard::new();
board.columns[1].push(card(Suit::Club, 13), true);
board.columns[1].push(card(Suit::Heart, 9), false);
board.columns[2].push(card(Suit::Club, 1), true);
board.columns[2].push(card(Suit::Diamond, 9), false);
board.compute_signature();

for draw in [1, 3] {
assert_eq!(BoardEval::from_board(&board, draw).deadlock_count, 0);
}
}

#[test]
fn checkpoint_policy_base_limit_for_hidden_and_endgame_ranges() {
let policy = CheckpointPolicy {
Expand Down
114 changes: 113 additions & 1 deletion solver-core-rs/src/klondike/moves.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ pub enum KlondikeMove {
}

impl KlondikeMove {
// Generates all valid primitive moves.
// Generates legal primitive moves, using one representative empty destination.
pub fn find_candidate_moves(board: &KlondikeBoard, draw_advance: u8) -> Vec<KlondikeMove> {
let mut moves = Vec::with_capacity(32);
let first_empty = board.columns.iter().position(|column| column.len == 0);

// 1. Column to foundation.
for c in 0..7 {
Expand Down Expand Up @@ -77,6 +78,9 @@ impl KlondikeMove {
if src == dest {
continue;
}
if board.columns[dest].len == 0 && Some(dest) != first_empty {
continue;
}
if board.columns[dest].can_add_run(run_base) {
moves.push(KlondikeMove::ColumnToColumn {
source: src as u8,
Expand All @@ -91,6 +95,9 @@ impl KlondikeMove {
// 4. Stock to column.
if let Some(card) = board.stock_pile_card() {
for dest in 0..7 {
if board.columns[dest].len == 0 && Some(dest) != first_empty {
continue;
}
if board.columns[dest].can_add_run(card) {
moves.push(KlondikeMove::StockPileToColumn {
destination: dest as u8,
Expand All @@ -104,6 +111,9 @@ impl KlondikeMove {
for &suit in crate::common::card::Suit::ALL.iter() {
if let Some(card) = board.top_of_foundation(suit) {
for dest in 0..7 {
if board.columns[dest].len == 0 && Some(dest) != first_empty {
continue;
}
if board.columns[dest].can_add_run(card) {
moves.push(KlondikeMove::FoundationToColumn {
destination: dest as u8,
Expand Down Expand Up @@ -290,3 +300,105 @@ pub fn find_stock_to_foundation_moves(board: &KlondikeBoard) -> Vec<KlondikeMove
}
vec![]
}

#[cfg(test)]
mod tests {
use super::*;
use crate::common::card::Suit;

#[test]
fn kings_use_one_empty_destination_from_tableau_stock_and_foundation() {
for empty_columns in [vec![1, 4], vec![4], vec![]] {
let mut board = KlondikeBoard::new();
board.columns[0].push(Card::new(Suit::Diamond, 2), true);
board.columns[0].push(Card::new(Suit::Heart, 13), false);
for column in 1..7 {
if !empty_columns.contains(&column) {
board.columns[column].push(Card::new(Suit::Spade, 6 + column as u8), false);
}
}
board.stock[0] = Card::new(Suit::Diamond, 13);
board.stock_len = 1;
board.stock_index = 1;
board.foundation[Suit::Club as usize] = 13;
board.compute_signature();
for draw in [1, 3] {
let moves = KlondikeMove::find_candidate_moves(&board, draw);
for origin in 0..3 {
let destinations: Vec<_> = moves
.iter()
.filter_map(|m| match (origin, m) {
(
0,
KlondikeMove::ColumnToColumn {
source: 0,
destination,
count: 1,
},
)
| (1, KlondikeMove::StockPileToColumn { destination, .. })
| (2, KlondikeMove::FoundationToColumn { destination, .. }) => {
Some(*destination as usize)
}
_ => None,
})
.collect();
assert_eq!(
destinations,
empty_columns
.first()
.copied()
.into_iter()
.collect::<Vec<_>>()
);
}
if let Some(&destination) = empty_columns.first() {
let m = KlondikeMove::ColumnToColumn {
source: 0,
destination: destination as u8,
count: 1,
};
assert_eq!(m.apply(board).unwrap().columns[0].face_down_len, 0);
}
}
}
}

#[test]
fn nonempty_destinations_keep_all_legal_tableau_stock_and_foundation_moves() {
let mut board = KlondikeBoard::new();
board.columns[0].push(Card::new(Suit::Heart, 7), false);
for (column, suit, rank) in [
(1, Suit::Club, 8),
(2, Suit::Spade, 8),
(3, Suit::Club, 7),
(4, Suit::Spade, 7),
] {
board.columns[column].push(Card::new(suit, rank), false);
}
board.stock[0] = Card::new(Suit::Diamond, 7);
board.stock_len = 1;
board.stock_index = 1;
board.foundation[Suit::Heart as usize] = 6;
for draw in [1, 3] {
let moves = KlondikeMove::find_candidate_moves(&board, draw);
for destination in [1, 2] {
assert!(moves.contains(&KlondikeMove::ColumnToColumn {
source: 0,
destination,
count: 1
}));
assert!(moves.contains(&KlondikeMove::StockPileToColumn {
destination,
card: Card::new(Suit::Diamond, 7)
}));
}
for destination in [3, 4] {
assert!(moves.contains(&KlondikeMove::FoundationToColumn {
destination,
card: Card::new(Suit::Heart, 6)
}));
}
}
}
}
Loading
Loading