Skip to content

Commit cc99c8e

Browse files
author
Ferdinand Schober
committed
fix unique path calculation
1 parent f54086e commit cc99c8e

4 files changed

Lines changed: 172 additions & 29 deletions

File tree

solitaire-game/src/solver.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ fn calculate_unique_paths(
135135
let feasible = feasible.0.clone();
136136
let wake = wake.clone();
137137
let task = thread_pool.spawn(async move {
138-
let unique_paths = solitaire_solver::all_unique_paths(feasible.iter().copied());
138+
// `None` = all cores: this already runs on the async pool, off the main thread,
139+
// so there is no frame budget to protect here
140+
let unique_paths = solitaire_solver::all_unique_paths(feasible.iter().copied(), None);
139141
info!("unique solutions: {}", unique_paths.len());
140142

141143
let mut command_queue = CommandQueue::default();

solitaire-solver/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@ pub use calc_naive::calculate_all_solutions_naive;
2727
pub use calc_success::calculate_p_random_chance_success;
2828
pub use feasible::calculate_feasible_set;
2929
pub use solution::print_solution;
30-
pub use unique_solutions::{all_unique_paths, all_unique_solutions};
30+
pub use unique_solutions::{all_unique_board_paths, all_unique_paths, all_unique_solutions};

solitaire-solver/src/unique_solutions.rs

Lines changed: 156 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
use crate::solution::SolutionMultiset;
2+
use crate::dir::Dir;
3+
use crate::par;
24
use crate::{Board, Move};
35
use crate::{HashMap, HashSet, Solution};
4-
use std::array;
56
use std::collections::BTreeMap;
7+
use std::num::NonZero;
68

79
/// we define two solutions as "equal" when the
810
/// multiset of steps is equivalent between them
@@ -135,30 +137,160 @@ impl ZobristTable {
135137

136138
type MultisetHash = u64;
137139

138-
#[allow(unused)]
139-
pub fn all_unique_paths(feasible: impl IntoIterator<Item = Board>) -> HashMap<Board, u64> {
140-
let mut number_of_combinations: HashMap<Board, u64> = HashMap::default();
141-
let mut boards: [Vec<Board>; 33] = array::from_fn(|_| Default::default());
142-
let mut feasible_set: HashSet<Board> = HashSet::default();
143-
for board in feasible.into_iter() {
144-
feasible_set.insert(board);
145-
boards[board.count_pegs()].push(board);
140+
/// Upper bound on the moves available from one board.
141+
///
142+
/// The cross holds 38 collinear triples and each can be jumped from either end, so no
143+
/// position can offer more than 76. Lets the successor buffer live on the stack instead of
144+
/// being heap-allocated per board - the old code allocated a `Vec` for every one of the
145+
/// 1_679_072 feasible boards.
146+
const MAX_MOVES: usize = 76;
147+
148+
/// How to treat two different moves from the same board that reach the same normalized
149+
/// successor - see [`all_unique_paths`] and [`all_unique_board_paths`].
150+
#[derive(Clone, Copy, PartialEq, Eq)]
151+
enum PathKind {
152+
/// Count both. Paths are sequences of *moves*.
153+
MoveSequences,
154+
/// Count once. Paths are sequences of *normalized boards*.
155+
BoardSequences,
156+
}
157+
158+
/// For every feasible board, the number of distinct **move sequences** taking it to the
159+
/// solved board.
160+
///
161+
/// From the start board this is 40_861_647_040_079_968, the published solution count for the
162+
/// central game, which is what pins this variant as correct.
163+
///
164+
/// Two different moves reaching the same normalized successor count as two paths here,
165+
/// because they are two different sequences of moves. [`all_unique_board_paths`] is the
166+
/// variant that collapses them.
167+
pub fn all_unique_paths(
168+
feasible: impl IntoIterator<Item = Board>,
169+
threads: Option<NonZero<usize>>,
170+
) -> HashMap<Board, u64> {
171+
count_paths(feasible, PathKind::MoveSequences, threads)
172+
}
173+
174+
/// For every feasible board, the number of distinct sequences of **normalized boards**
175+
/// taking it to the solved board.
176+
///
177+
/// Differs from [`all_unique_paths`] only where a board has two moves reaching the same
178+
/// normalized successor - a board with a nontrivial stabilizer. From the start board this is
179+
/// 4_750_671_971_732_176 against the move-sequence count's 40_861_647_040_079_968.
180+
pub fn all_unique_board_paths(
181+
feasible: impl IntoIterator<Item = Board>,
182+
threads: Option<NonZero<usize>>,
183+
) -> HashMap<Board, u64> {
184+
count_paths(feasible, PathKind::BoardSequences, threads)
185+
}
186+
187+
/// Shared layered dynamic program: a board's count is the sum of its successors' counts, and
188+
/// a move always removes exactly one peg, so layer `k` depends only on layer `k - 1`.
189+
///
190+
/// Three things this does differently from the obvious formulation, in decreasing order of
191+
/// what they were worth:
192+
///
193+
/// - Successors come from `normalize_after_move`, not `possible_moves` + `normalize_all`. The
194+
/// latter normalizes each successor from scratch, which is 8 symmetry transforms *per
195+
/// successor*; the former takes the board's 8 symmetries once and XORs the move's mask into
196+
/// each, which is the identity `g(b ^ m) = g(b) ^ g(m)` that `board.rs` already relies on.
197+
/// Over 17.2M successors that is 137M symmetry transforms replaced by 17.2M XOR rounds.
198+
/// - One `HashMap<Board, u32>` index instead of a `HashSet` plus a `HashMap`, so a successor
199+
/// costs one hash lookup rather than two: finding it *is* finding where its count lives.
200+
/// - The successor buffer is a stack array reused per board rather than a fresh `Vec`, which
201+
/// removes one heap allocation per feasible board.
202+
///
203+
/// Each layer is then independent, so it is evaluated in parallel and written back in order.
204+
fn count_paths(
205+
feasible: impl IntoIterator<Item = Board>,
206+
kind: PathKind,
207+
threads: Option<NonZero<usize>>,
208+
) -> HashMap<Board, u64> {
209+
let mut index: HashMap<Board, u32> = HashMap::default();
210+
// `Board::SLOTS + 2` rather than 33: `count_pegs` reaches `SLOTS`, and the old fixed
211+
// `[_; 33]` would have panicked on a board with that many pegs. No feasible board has
212+
// (the start has one hole) but nothing here guarantees the input is the feasible set.
213+
let mut by_pegs: Vec<Vec<Board>> = vec![Vec::new(); Board::SLOTS + 2];
214+
for board in feasible {
215+
let next = index.len() as u32;
216+
index.insert(board, next);
217+
by_pegs[board.count_pegs()].push(board);
146218
}
147-
number_of_combinations.insert(Board::solved(), 1);
148-
for boards in boards.iter().skip(2) {
149-
for board in boards {
150-
let mut next = Board::possible_moves(&[*board]);
151-
Board::normalize_all(&mut next);
152-
next.dedup();
153-
154-
let count = next
155-
.into_iter()
156-
.filter(|b| feasible_set.contains(b))
157-
.map(|b| number_of_combinations[&b])
158-
.sum();
159-
let entry = number_of_combinations.entry(*board).or_default();
160-
*entry = count;
219+
220+
let mut counts = vec![0u64; index.len()];
221+
if let Some(&solved) = index.get(&Board::solved()) {
222+
counts[solved as usize] = 1;
223+
}
224+
225+
// `None` means "as many as the machine has", matching `calculate_feasible_set`. Passing
226+
// the count down to `par::parallel` is what makes `--threads 1` actually sequential:
227+
// `par_map_chunks` short-circuits to one chunk on the calling thread at 1, whereas
228+
// `configure_thread_pool` cannot help - it leaves an already-built pool alone, so by this
229+
// point the pool's width is whatever the first caller asked for.
230+
let threads = threads.unwrap_or_else(par::num_threads).get();
231+
// skip(2): layer 0 is empty and layer 1 is the solved board, seeded above
232+
for layer in by_pegs.iter().skip(2) {
233+
if layer.is_empty() {
234+
continue;
235+
}
236+
// Read-only view of everything below this layer; the writes land afterwards, so no
237+
// two tasks touch the same count and none reads one this layer is producing.
238+
let (index_ref, counts_ref) = (&index, &counts);
239+
let layer_counts: Vec<u64> = par::parallel(layer, threads, move |chunk| {
240+
chunk
241+
.iter()
242+
.map(|board| {
243+
let mut buffer = [Board::empty(); MAX_MOVES];
244+
let successors = successors_of(*board, &mut buffer, kind);
245+
successors
246+
.iter()
247+
.filter_map(|s| index_ref.get(s))
248+
.map(|&i| counts_ref[i as usize])
249+
.sum()
250+
})
251+
.collect()
252+
});
253+
for (board, count) in layer.iter().zip(layer_counts) {
254+
counts[index[board] as usize] = count;
255+
}
256+
}
257+
258+
index
259+
.iter()
260+
.map(|(board, &i)| (*board, counts[i as usize]))
261+
.collect()
262+
}
263+
264+
/// Fills `buffer` with `board`'s normalized successors and returns the filled prefix,
265+
/// deduplicated when `kind` asks for it.
266+
///
267+
/// Deduplication sorts first, deliberately. A bare `dedup` removes only *adjacent* equals,
268+
/// and `possible_moves` groups its output by direction, so two moves in different directions
269+
/// reaching the same successor land far apart and survive it - 2_999 of them across the
270+
/// feasible set, each double-counting a subtree. That is what the previous version of this
271+
/// did, and it is why its answer matched neither of the two well-defined counts.
272+
fn successors_of(board: Board, buffer: &mut [Board; MAX_MOVES], kind: PathKind) -> &[Board] {
273+
let syms = board.symmetries();
274+
let mut len = 0;
275+
for dir in Dir::enumerate() {
276+
for idx in board.mov_pattern_mask(dir) {
277+
debug_assert!(len < MAX_MOVES, "more than {MAX_MOVES} moves from one board");
278+
buffer[len] = Board::normalize_after_move(&syms, idx, dir);
279+
len += 1;
280+
}
281+
}
282+
let filled = &mut buffer[..len];
283+
if kind == PathKind::BoardSequences {
284+
filled.sort_unstable();
285+
// in-place dedup: `slice::partition_dedup` would do this but is still unstable
286+
let mut write = 0;
287+
for read in 0..len {
288+
if write == 0 || filled[write - 1] != filled[read] {
289+
filled[write] = filled[read];
290+
write += 1;
291+
}
161292
}
293+
len = write;
162294
}
163-
number_of_combinations
295+
&buffer[..len]
164296
}

src/main.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,19 @@ fn main() {
215215
log::info!("unique solutions: {}", solutions.len());
216216
}
217217
Command::UniquePaths => {
218-
let feasible = solitaire_solver::calculate_feasible_set(None);
218+
let feasible = solitaire_solver::calculate_feasible_set(args.threads);
219219
log::info!("feasible: {}", feasible.len());
220-
let paths = solitaire_solver::all_unique_paths(feasible);
221-
log::info!("unique paths: {}", paths.get(&Board::default()).unwrap());
220+
let moves = solitaire_solver::all_unique_paths(feasible.clone(), args.threads);
221+
log::info!(
222+
"distinct move sequences: {}",
223+
moves.get(&Board::default()).unwrap()
224+
);
225+
let boards =
226+
solitaire_solver::all_unique_board_paths(feasible, args.threads);
227+
log::info!(
228+
"distinct board sequences: {}",
229+
boards.get(&Board::default()).unwrap()
230+
);
222231
}
223232
}
224233
}

0 commit comments

Comments
 (0)