@@ -183,6 +183,153 @@ pub fn all_unique_solutions(
183183 search. solutions
184184}
185185
186+ /// Sentinel in a [`JumpMap`] for the one peg that is never removed.
187+ pub const NOT_REMOVED : u8 = u8:: MAX ;
188+
189+ /// Which peg jumped which, both identified by the slot the peg *started* in.
190+ ///
191+ /// Indexed by the victim's starting slot; the value is the jumper's. Every peg is removed
192+ /// exactly once - 32 pegs down to 1, one removal per move - so this is a *function* with 31
193+ /// entries and distinct keys, not a multiset. That is what makes the whole equivalence
194+ /// cheaper than the move-multiset one: no occurrence counting, and the canonical form is a
195+ /// fixed-size array rather than a `BTreeMap`.
196+ ///
197+ /// Raw bit positions, so the array is 64 wide with the off-board indices unused.
198+ pub type JumpMap = [ u8 ; 64 ] ;
199+
200+ /// Skip and target bit positions of the move starting at `idx` and going in `dir`.
201+ fn move_bits ( idx : usize , dir : Dir ) -> ( usize , usize ) {
202+ let step = match dir {
203+ Dir :: North => -( Board :: REPR as isize ) ,
204+ Dir :: South => Board :: REPR as isize ,
205+ Dir :: West => -1 ,
206+ Dir :: East => 1 ,
207+ } ;
208+ let at = |k : isize | ( idx as isize + step * k) as usize ;
209+ ( at ( 1 ) , at ( 2 ) )
210+ }
211+
212+ /// Depth-first search collecting the distinct [`JumpMap`]s that reach the solved board.
213+ ///
214+ /// Separate from [`Search`] rather than sharing it, because the two equivalences need
215+ /// genuinely different state: the multiset one is a function of the moves alone, while this
216+ /// one depends on *peg identity*, which is a function of the whole path.
217+ ///
218+ /// That difference is also why this may not be tractable where the multiset version is. The
219+ /// multiset version prunes on the multiset alone, since a move is an XOR with a fixed mask
220+ /// and so the multiset determines the board. Here two paths can reach the same board with the
221+ /// same partial jump map and still have their pegs arranged differently, which changes every
222+ /// pair they can produce afterwards - so the state that determines the future is
223+ /// `(board, identity assignment, partial map)`, and there are far more of those than boards.
224+ struct JumpSearch < ' a > {
225+ feasible : & ' a HashSet < Board > ,
226+ /// random value per (slot, peg identity), for hashing the identity assignment
227+ placed : Vec < u64 > ,
228+ /// random value per (victim, jumper), for hashing the partial map
229+ jumped : Vec < u64 > ,
230+ /// slot -> starting slot of the peg currently in it; only occupied slots are meaningful
231+ identity : JumpMap ,
232+ /// the canonical form being built
233+ map : JumpMap ,
234+ visited : FxHashSet < ( Board , u64 ) > ,
235+ maps : FxHashSet < JumpMap > ,
236+ states : u64 ,
237+ }
238+
239+ impl JumpSearch < ' _ > {
240+ fn visit ( & mut self , board : Board , hash : u64 ) {
241+ if board. is_solved ( ) {
242+ self . maps . insert ( self . map ) ;
243+ return ;
244+ }
245+ self . states += 1 ;
246+ if self . states . is_multiple_of ( 50_000_000 ) {
247+ log:: info!(
248+ "jump maps: {} states expanded, {} distinct maps so far" ,
249+ self . states,
250+ self . maps. len( )
251+ ) ;
252+ }
253+
254+ let syms = board. symmetries ( ) ;
255+ for dir in Dir :: enumerate ( ) {
256+ for idx in board. mov_pattern_mask ( dir) {
257+ if !self
258+ . feasible
259+ . contains ( & Board :: normalize_after_move ( & syms, idx, dir) )
260+ {
261+ continue ;
262+ }
263+ let ( skip, target) = move_bits ( idx, dir) ;
264+ let jumper = self . identity [ idx] ;
265+ let victim = self . identity [ skip] ;
266+
267+ // The hash covers the identity assignment *and* the partial map, so that two
268+ // states are merged only when both agree - see the note on the struct.
269+ let next_hash = hash
270+ ^ self . placed [ idx * 64 + jumper as usize ]
271+ ^ self . placed [ skip * 64 + victim as usize ]
272+ ^ self . placed [ target * 64 + jumper as usize ]
273+ ^ self . jumped [ victim as usize * 64 + jumper as usize ] ;
274+
275+ let next_board = board. toggle_mov_idx_unchecked ( idx, dir) ;
276+ if !self . visited . insert ( ( next_board, next_hash) ) {
277+ continue ;
278+ }
279+
280+ let vacated = self . identity [ target] ;
281+ self . identity [ target] = jumper;
282+ self . map [ victim as usize ] = jumper;
283+
284+ self . visit ( next_board, next_hash) ;
285+
286+ self . map [ victim as usize ] = NOT_REMOVED ;
287+ self . identity [ target] = vacated;
288+ }
289+ }
290+ }
291+ }
292+
293+ /// Finds the distinct [`JumpMap`]s of all solutions from `start`.
294+ ///
295+ /// Two solutions are the same here when every peg was jumped by the same peg, tracking pegs
296+ /// by where they started - the equivalence [`all_unique_solutions`]'s move multiset does not
297+ /// capture, since that identifies moves by *slots* and so cannot tell which physical peg made
298+ /// the jump.
299+ pub fn all_unique_jump_maps (
300+ start : Board ,
301+ feasible : impl IntoIterator < Item = Board > ,
302+ ) -> FxHashSet < JumpMap > {
303+ log:: info!( "calculating unique jump maps ...." ) ;
304+ let feasible: HashSet < Board > = feasible. into_iter ( ) . collect ( ) ;
305+
306+ const fn mix ( mut x : u64 ) -> u64 {
307+ x = x. wrapping_add ( 0x9E37_79B9_7F4A_7C15 ) ;
308+ x = ( x ^ ( x >> 30 ) ) . wrapping_mul ( 0xBF58_476D_1CE4_E5B9 ) ;
309+ x = ( x ^ ( x >> 27 ) ) . wrapping_mul ( 0x94D0_49BB_1331_11EB ) ;
310+ x ^ ( x >> 31 )
311+ }
312+
313+ let mut identity = [ NOT_REMOVED ; 64 ] ;
314+ for bit in start {
315+ identity[ bit] = bit as u8 ;
316+ }
317+
318+ let mut search = JumpSearch {
319+ feasible : & feasible,
320+ placed : ( 0 ..64 * 64 ) . map ( |i| mix ( i as u64 ) ) . collect ( ) ,
321+ jumped : ( 0 ..64 * 64 ) . map ( |i| mix ( 0x5EED_0000_0000_0000 | i as u64 ) ) . collect ( ) ,
322+ identity,
323+ map : [ NOT_REMOVED ; 64 ] ,
324+ visited : FxHashSet :: default ( ) ,
325+ maps : FxHashSet :: default ( ) ,
326+ states : 0 ,
327+ } ;
328+ search. visit ( start, 0 ) ;
329+ log:: info!( "jump maps: {} states expanded" , search. states) ;
330+ search. maps
331+ }
332+
186333#[ allow( unused) ]
187334fn canonicalize (
188335 unique_solutions : FxHashSet < SolutionMultiset > ,
@@ -225,29 +372,6 @@ fn canonicalize(
225372 unique_solutions
226373}
227374
228- /// Precomputed random values for each (Step, occurrence_index) pair.
229- /// occurrence_index 0 means "going from 0 to 1 occurrences", etc.
230- #[ derive( Default ) ]
231- struct ZobristTable {
232- table : std:: collections:: HashMap < ( Move , usize ) , u64 > ,
233- }
234-
235- impl ZobristTable {
236- fn delta ( & mut self , step : & Move , new_count : usize ) -> u64 {
237- // XOR out the old count contribution, XOR in the new one
238- let old = self . get ( step, new_count - 1 ) ;
239- let new = self . get ( step, new_count) ;
240- old ^ new
241- }
242-
243- fn get ( & mut self , step : & Move , count : usize ) -> u64 {
244- * self
245- . table
246- . entry ( ( * step, count) )
247- . or_insert_with ( rand:: random)
248- }
249- }
250-
251375/// Upper bound on the moves available from one board.
252376///
253377/// The cross holds 38 collinear triples and each can be jumped from either end, so no
0 commit comments