Skip to content

Commit c53c7b0

Browse files
menjarazclaude
andcommitted
Relax Possibility to Clone and add opt-in parallel search
- Problem::Possibility now only requires Clone instead of Copy. Every Copy type is already Clone, so this is backward compatible for all existing implementations, while also allowing heap-backed decision types. - Add an opt-in `parallel` feature (optional rayon dependency) with a new parallel_solutions function: it fans out the independent branches rooted at each top-level possibility across threads, each branch still walked sequentially by the existing Solutions iterator. Solutions::new and its bounds are untouched. - CI now also runs the test suite with --features parallel. - README documents both additions and their rationale. - Ignore GENERICITY.md (private working notes, not meant to be tracked). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 4149c37 commit c53c7b0

6 files changed

Lines changed: 139 additions & 6 deletions

File tree

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ jobs:
1111
- uses: actions/checkout@v5
1212
- name: Run tests
1313
run: cargo test --all-targets --verbose
14+
- name: Run tests (parallel feature)
15+
run: cargo test --all-targets --features parallel --verbose
1416
- name: Run knights-journey
1517
run: cargo run --example knights_journey --release
1618
- name: Run sudoku

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
/target
2+
/GENERICITY.md

Cargo.lock

Lines changed: 55 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@ categories = []
2424
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
2525

2626
[dependencies]
27+
rayon = { version = "1", optional = true }
28+
29+
[features]
30+
parallel = ["dep:rayon"]

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,21 @@
55
Generic implementation of backtracking together with some examples. This came out of a code dojo
66
session with some of my colleagues (<https://github.com/pacman82/cobra-kai-code-dojo>). I found it
77
neat enough to put it into its own repostiory.
8+
9+
---
10+
11+
## Fork additions
12+
13+
This fork adds test coverage (the library and all three examples now have unit tests; CI runs
14+
`cargo test --all-targets` against both the default and `parallel` feature sets) and two
15+
backward-compatible extensions to the `Problem`/`Solutions` API:
16+
17+
- **`Problem::Possibility` now requires only `Clone`, not `Copy`.** Every `Copy` type is already
18+
`Clone`, so existing implementations are unaffected; the weaker bound additionally allows
19+
heap-backed decision types (an owned `String`, a `Vec`, ...) without forcing them through an
20+
artificial handle/index indirection just to satisfy the trait.
21+
- **An opt-in `parallel` feature adds `parallel_solutions`.** It searches the independent branches
22+
rooted at each top-level possibility in parallel (via `rayon`), with each branch still walked
23+
sequentially by an ordinary `Solutions` iterator on its own thread. The extra `Clone + Send +
24+
Sync` bounds only apply at that new call site — `Solutions::new` and its bounds are untouched,
25+
and the `rayon` dependency is not compiled unless the feature is enabled.

src/lib.rs

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ pub trait Problem {
1212
/// Describes a decision made in a problem state leading to a new candidate for a solution. E.g.
1313
/// which field to jump to in a knights journey problem or which digit to write into a cell for
1414
/// a sudoku puzzle.
15-
type Possibility: Copy;
15+
type Possibility: Clone;
1616
/// Final state we are interested in. E.g. The history of moves made for a knights journey, or
1717
/// the final distribution of digits in the cells of a sudoku puzzle.
1818
type Solution;
@@ -56,7 +56,7 @@ impl<G: Problem> Solutions<G> {
5656
.iter()
5757
.map(|pos| Candidate {
5858
count: 1,
59-
possibility: *pos,
59+
possibility: pos.clone(),
6060
})
6161
.collect();
6262
Self {
@@ -66,6 +66,45 @@ impl<G: Problem> Solutions<G> {
6666
current: init,
6767
}
6868
}
69+
70+
/// Restricts the initial search frontier to a single possibility, leaving the rest of the
71+
/// tree walk identical to [`Solutions::new`]. Used to partition the root of the search tree
72+
/// into independent branches, e.g. for [`parallel_solutions`].
73+
fn seeded(current: G, first: G::Possibility) -> Self {
74+
Self {
75+
decisions: Vec::new(),
76+
open: vec![Candidate {
77+
count: 1,
78+
possibility: first,
79+
}],
80+
history: Vec::new(),
81+
current,
82+
}
83+
}
84+
}
85+
86+
/// Explores the independent branches rooted at each of the initial possibilities of `init` in
87+
/// parallel, each branch searched sequentially by an ordinary [`Solutions`] iterator on its own
88+
/// thread. Requires the `parallel` feature.
89+
///
90+
/// This only requires [`Clone`] on `P` (to fork one problem instance per root branch) and
91+
/// [`Send`] bounds (to move those instances across threads); it does not change [`Problem`] or
92+
/// [`Solutions`] in any way, so it is purely additive to the crate's API.
93+
#[cfg(feature = "parallel")]
94+
pub fn parallel_solutions<P>(init: P) -> impl rayon::iter::ParallelIterator<Item = P::Solution>
95+
where
96+
P: Problem + Clone + Send + Sync,
97+
P::Solution: Send,
98+
P::Possibility: Send,
99+
{
100+
use rayon::iter::{IntoParallelIterator, ParallelIterator};
101+
102+
let mut roots = Vec::new();
103+
init.extend_possibilities(&mut roots, &[]);
104+
105+
roots.into_par_iter().flat_map_iter(move |first_move| {
106+
Solutions::seeded(init.clone(), first_move)
107+
})
69108
}
70109

71110
impl<G: Problem> Iterator for Solutions<G> {
@@ -86,7 +125,7 @@ impl<G: Problem> Iterator for Solutions<G> {
86125
}
87126

88127
// We advance one move deeper into the search tree
89-
self.current.what_if(mov);
128+
self.current.what_if(mov.clone());
90129
self.history.push(mov);
91130

92131
// Emit solution
@@ -99,9 +138,9 @@ impl<G: Problem> Iterator for Solutions<G> {
99138
self.current
100139
.extend_possibilities(&mut self.decisions, &self.history);
101140
self.open
102-
.extend(self.decisions.iter().map(|&position| Candidate {
141+
.extend(self.decisions.iter().map(|position| Candidate {
103142
count: count + 1,
104-
possibility: position,
143+
possibility: position.clone(),
105144
}))
106145
}
107146
None
@@ -180,6 +219,7 @@ mod tests {
180219

181220
/// Only yields permutations of `0..n`, by refusing to reuse a value already present in
182221
/// `history`. Checks that backtracking correctly prunes branches based on sibling state.
222+
#[derive(Clone)]
183223
struct DistinctPermutations {
184224
n: u8,
185225
}
@@ -217,6 +257,20 @@ mod tests {
217257
}
218258
}
219259

260+
#[cfg(feature = "parallel")]
261+
#[test]
262+
fn parallel_search_yields_the_same_solutions_as_sequential_search() {
263+
use rayon::iter::ParallelIterator;
264+
265+
let mut sequential: Vec<_> = Solutions::new(DistinctPermutations { n: 4 }).collect();
266+
let mut parallel: Vec<_> = parallel_solutions(DistinctPermutations { n: 4 }).collect();
267+
268+
sequential.sort();
269+
parallel.sort();
270+
assert_eq!(sequential, parallel);
271+
assert_eq!(24, parallel.len()); // 4! permutations
272+
}
273+
220274
/// Tracks a running sum as cached state via `what_if`/`undo`, and cross checks it against a
221275
/// sum computed fresh from `history` on every candidate solution. This exercises that
222276
/// `Solutions` unwinds (`undo`s) and replays (`what_if`s) the cache correctly when

0 commit comments

Comments
 (0)