Repository: cubing/twsearch
Pull request #129: Remove move class limit for the canonical FSM
Author: login: ArhanChaudhary
Created: 2025-03-17T19:55:21Z | Merged: 2025-03-19T01:12:38Z

This implementation uses a Vec based storage representation to allow for an unlimited number of move classes. I am unsure how to test my code however, please let me know if this seems right.


Diff:
diff --git a/src/rs/_cli/commands/canonical_algs.rs b/src/rs/_cli/commands/canonical_algs.rs
index 495148c8..55dff274 100644
--- a/src/rs/_cli/commands/canonical_algs.rs
+++ b/src/rs/_cli/commands/canonical_algs.rs
@@ -18,8 +18,7 @@ pub fn canonical_algs(args: &CanonicalAlgsArgs) -> Result<(), CommandError> {
         false,
     )?;
 
-    let canonical_fsm = CanonicalFSM::try_new(kpuzzle, search_generators, Default::default())
-        .expect("Expected to work!");
+    let canonical_fsm = CanonicalFSM::new(kpuzzle, search_generators, Default::default());
     dbg!(canonical_fsm);
 
     Ok(())
diff --git a/src/rs/_internal/canonical_fsm/canonical_fsm.rs b/src/rs/_internal/canonical_fsm/canonical_fsm.rs
index c721cdc1..9f9b5de1 100644
--- a/src/rs/_internal/canonical_fsm/canonical_fsm.rs
+++ b/src/rs/_internal/canonical_fsm/canonical_fsm.rs
@@ -8,30 +8,32 @@ use cubing::alg::QuantumMove;
 
 use crate::{
     _internal::{
-        errors::SearchError, puzzle_traits::puzzle_traits::SemiGroupActionPuzzle,
-        search::indexed_vec::IndexedVec,
+        puzzle_traits::puzzle_traits::SemiGroupActionPuzzle, search::indexed_vec::IndexedVec,
     },
     whole_number_newtype,
 };
 
 use super::search_generators::{MoveTransformationInfo, SearchGenerators};
 
-const MAX_NUM_MOVE_CLASSES: usize = usize::BITS as usize;
-
 whole_number_newtype!(MoveClassIndex, usize);
 
 // Bit N is indexed by a `MoveClassIndex` value of N.
-whole_number_newtype!(MoveClassMask, u64);
+#[derive(Debug, Eq, Hash, PartialEq, Clone)]
+struct MoveClassMask(Vec<bool>);
 
 impl BitAndAssign for MoveClassMask {
     fn bitand_assign(&mut self, rhs: Self) {
-        self.0 = self.0 & rhs.0
+        for (entry, rhs_entry) in self.0.iter_mut().zip(rhs.0.iter()) {
+            *entry &= *rhs_entry;
+        }
     }
 }
 
 impl BitOrAssign for MoveClassMask {
     fn bitor_assign(&mut self, rhs: Self) {
-        self.0 = self.0 | rhs.0
+        for (entry, rhs_entry) in self.0.iter_mut().zip(rhs.0.iter()) {
+            *entry |= *rhs_entry;
+        }
     }
 }
 
@@ -48,8 +50,8 @@ impl MaskToState {
         self.0.insert(mask, state);
     }
 
-    pub fn get(&self, mask: MoveClassMask) -> Option<CanonicalFSMState> {
-        self.0.get(&mask).copied() // TODO: figure out how to do this safely and most performantly
+    pub fn get(&self, mask: &MoveClassMask) -> Option<CanonicalFSMState> {
+        self.0.get(mask).copied() // TODO: figure out how to do this safely and most performantly
     }
 }
 
@@ -66,20 +68,13 @@ impl StateToMask {
         self.0.push(mask);
     }
 
-    pub fn set(&mut self, state: CanonicalFSMState, value: MoveClassMask) {
-        self.0.set(state, value);
-    }
-
-    pub fn get(&self, state: CanonicalFSMState) -> MoveClassMask {
-        *self.0.at(state)
+    pub fn get(&self, state: CanonicalFSMState) -> &MoveClassMask {
+        self.0.at(state)
     }
 }
 
 #[derive(Debug)]
 pub struct CanonicalFSM<TPuzzle: SemiGroupActionPuzzle> {
-    // disallowed_move_classes, indexed by state ordinal, holds the set of move classes that should
-    // not be made from this state.
-    // disallowed_move_classes: StateToMask,
     pub(crate) next_state_lookup:
         IndexedVec<CanonicalFSMState, IndexedVec<MoveClassIndex, CanonicalFSMState>>,
 
@@ -112,24 +107,18 @@ impl CanonicalFSMConstructionOptions {
 }
 
 impl<TPuzzle: SemiGroupActionPuzzle> CanonicalFSM<TPuzzle> {
-    // TODO: Return a more specific error.
     /// Pass `Default::default()` as for `options` when no options are needed.
-    pub fn try_new(
+    pub fn new(
         tpuzzle: TPuzzle,
         generators: SearchGenerators<TPuzzle>, // TODO: make this a field in the options?
         options: CanonicalFSMConstructionOptions,
-    ) -> Result<CanonicalFSM<TPuzzle>, SearchError> {
+    ) -> CanonicalFSM<TPuzzle> {
         let num_move_classes = generators.by_move_class.len();
-        if num_move_classes > MAX_NUM_MOVE_CLASSES {
-            return Err(SearchError {
-                description: "Too many move classes!".to_owned(),
-            });
-        }
 
         let mut commutes: Vec<MoveClassMask> =
-            vec![MoveClassMask((1 << num_move_classes) - 1); num_move_classes];
+            vec![MoveClassMask(vec![true; num_move_classes]); num_move_classes];
         let mut forbidden_transitions: Vec<MoveClassMask> =
-            vec![MoveClassMask(0); num_move_classes];
+            vec![MoveClassMask(vec![false; num_move_classes]); num_move_classes];
 
         // Written this way so if we later iterate over all moves instead of
         // all move classes. This is because multiples can commute differently than their quantum values.
@@ -144,12 +133,12 @@ impl<TPuzzle: SemiGroupActionPuzzle> CanonicalFSM<TPuzzle> {
                 let move1_info = &generators.by_move_class.at(i)[0];
                 let move2_info = &generators.by_move_class.at(j)[0];
                 if !tpuzzle.do_moves_commute(move1_info, move2_info) {
-                    commutes[*i] &= MoveClassMask(!(1 << *j));
-                    commutes[*j] &= MoveClassMask(!(1 << *i));
+                    commutes[*i].0[*j] = false;
+                    commutes[*j].0[*i] = false;
                 }
                 if options.is_transition_forbidden(move1_info, move2_info) {
-                    forbidden_transitions[*i] |= MoveClassMask(1 << *j);
-                    forbidden_transitions[*j] |= MoveClassMask(1 << *i);
+                    forbidden_transitions[*i].0[*j] = true;
+                    forbidden_transitions[*j].0[*i] = true;
                 }
             }
         }
@@ -160,65 +149,71 @@ impl<TPuzzle: SemiGroupActionPuzzle> CanonicalFSM<TPuzzle> {
         > = IndexedVec::default();
 
         let mut mask_to_state = MaskToState::default();
-        mask_to_state.insert(MoveClassMask(0), CANONICAL_FSM_START_STATE);
-        let mut state_to_mask = StateToMask::new(MoveClassMask(0));
+        mask_to_state.insert(
+            MoveClassMask(vec![false; num_move_classes]),
+            CANONICAL_FSM_START_STATE,
+        );
         // state_to_mask, indexed by state ordinal,  holds the set of move classes in the
         // move sequence so far for which there has not been a subsequent move that does not
         // commute with that move.
-        let mut disallowed_move_classes = StateToMask::new(MoveClassMask(0));
+        let mut state_to_mask = StateToMask::new(MoveClassMask(vec![false; num_move_classes]));
 
         let mut queue_index: CanonicalFSMState = CANONICAL_FSM_START_STATE;
-        while Into::<usize>::into(queue_index) < state_to_mask.0.len() {
+        while usize::from(queue_index) < state_to_mask.0.len() {
             let mut next_state: IndexedVec<MoveClassIndex, CanonicalFSMState> =
                 IndexedVec::new(vec![ILLEGAL_FSM_STATE; num_move_classes]);
 
-            let dequeue_move_class_mask: MoveClassMask = state_to_mask.get(queue_index);
-            disallowed_move_classes.push(MoveClassMask(0));
+            let dequeue_move_class_mask = state_to_mask.get(queue_index).clone();
 
             queue_index += CanonicalFSMState(1);
-            let from_state = queue_index;
-
-            for move_class_index in generators.by_move_class.index_iter() {
-                let mut skip = false;
-                // If there's a greater move (multiple) in the state that
-                // commutes with this move's `move_class`, we can't move
-                // `move_class`.
-                skip |= (*dequeue_move_class_mask & *commutes[*move_class_index])
-                    >> (*move_class_index + 1)
-                    != 0;
-                skip |= (*dequeue_move_class_mask & *forbidden_transitions[*move_class_index]) != 0;
-                skip |= ((*dequeue_move_class_mask >> *move_class_index) & 1) != 0;
-                if skip {
-                    let new_value = MoveClassMask(
-                        *disallowed_move_classes.get(from_state) | (1 << *move_class_index),
-                    );
-                    disallowed_move_classes.set(from_state, new_value);
+
+            'outer: for move_class_index in generators.by_move_class.index_iter() {
+                for i in 0..num_move_classes {
+                    // If the transition is forbidden by the options, we can't
+                    // move `move_class`
+                    if dequeue_move_class_mask.0[i] && forbidden_transitions[*move_class_index].0[i]
+                    {
+                        continue 'outer;
+                    }
+
+                    // If there's a greater move (multiple) in the state that
+                    // commutes with this move's `move_class`, we can't move
+                    // `move_class`.
+                    if i > *move_class_index
+                        && dequeue_move_class_mask.0[i]
+                        && commutes[*move_class_index].0[i]
+                    {
+                        continue 'outer;
+                    }
+                }
+                if dequeue_move_class_mask.0[*move_class_index] {
                     continue;
                 }
-                // TODO implement bit arithmetic for whole number newtypes.
-                let mut next_state_bits = (*dequeue_move_class_mask & *commutes[*move_class_index])
-                    | (1 << *move_class_index);
+                let mut next_state_mask = dequeue_move_class_mask.clone();
+                next_state_mask &= commutes[*move_class_index].clone();
+                next_state_mask.0[*move_class_index] = true;
+
                 // If a pair of bits are set with the same commutating moves, we
                 // can clear out the higher ones. This optimization keeps the
                 // state count from going exponential for very big cubes.
                 for i in 0..num_move_classes {
-                    if (next_state_bits >> i) & 1 != 0 {
+                    if next_state_mask.0[i] {
                         for j in (i + 1)..num_move_classes {
-                            if ((next_state_bits >> j) & 1) != 0 && commutes[i] == commutes[j] {
-                                next_state_bits &= !(1 << i);
+                            if next_state_mask.0[j] && commutes[i] == commutes[j] {
+                                next_state_mask.0[i] = false;
                             }
                         }
                     }
                 }
 
-                let next_move_mask_class = MoveClassMask(next_state_bits);
+                let next_move_mask_class = next_state_mask;
 
                 next_state.set(
                     move_class_index,
-                    match mask_to_state.get(next_move_mask_class) {
+                    match mask_to_state.get(&next_move_mask_class) {
                         None => {
                             let next_state = CanonicalFSMState(state_to_mask.0.len());
-                            mask_to_state.insert(next_move_mask_class, next_state);
+                            mask_to_state.insert(next_move_mask_class.clone(), next_state);
                             state_to_mask.push(next_move_mask_class);
                             next_state
                         }
@@ -229,10 +224,10 @@ impl<TPuzzle: SemiGroupActionPuzzle> CanonicalFSM<TPuzzle> {
             next_state_lookup.push(next_state);
         }
 
-        Ok(Self {
+        Self {
             next_state_lookup,
             phantom_data: PhantomData,
-        })
+        }
     }
 
     pub(crate) fn next_state(
diff --git a/src/rs/_internal/gods_algorithm/gods_algorithm_table.rs b/src/rs/_internal/gods_algorithm/gods_algorithm_table.rs
index 6879882c..c2eda5d4 100644
--- a/src/rs/_internal/gods_algorithm/gods_algorithm_table.rs
+++ b/src/rs/_internal/gods_algorithm/gods_algorithm_table.rs
@@ -81,11 +81,11 @@ impl GodsAlgorithmSearch {
             quantum_metric,
             false,
         )?;
-        let canonical_fsm = CanonicalFSM::try_new(
+        let canonical_fsm = CanonicalFSM::new(
             kpuzzle.clone(),
             search_generators.clone(),
             Default::default(),
-        )?;
+        );
 
         let cached_inverses = IndexedVec::new(
             search_generators
diff --git a/src/rs/_internal/search/iterative_deepening/iterative_deepening_search.rs b/src/rs/_internal/search/iterative_deepening/iterative_deepening_search.rs
index 89f7513e..56f61433 100644
--- a/src/rs/_internal/search/iterative_deepening/iterative_deepening_search.rs
+++ b/src/rs/_internal/search/iterative_deepening/iterative_deepening_search.rs
@@ -209,11 +209,11 @@ impl<
             &options.metric,
             options.random_start,
         )?;
-        let canonical_fsm = CanonicalFSM::try_new(
+        let canonical_fsm = CanonicalFSM::new(
             tpuzzle.clone(),
             search_generators.clone(),
             options.canonical_fsm_construction_options,
-        )?; // TODO: avoid a clone
+        ); // TODO: avoid a clone
         let api_data = Arc::new(IterativeDeepeningSearchAPIData {
             search_generators,
             canonical_fsm,
diff --git a/src/rs/scramble/puzzles/big_cubes.rs b/src/rs/scramble/puzzles/big_cubes.rs
index bde8a829..62b21474 100644
--- a/src/rs/scramble/puzzles/big_cubes.rs
+++ b/src/rs/scramble/puzzles/big_cubes.rs
@@ -34,7 +34,7 @@ impl<TPuzzle: SemiGroupActionPuzzle> ScrambleInfo<TPuzzle> {
         let generators =
             SearchGenerators::try_new(tpuzzle, moves, &MetricEnum::Hand, false).unwrap();
         let canonical_fsm =
-            CanonicalFSM::try_new(tpuzzle.clone(), generators.clone(), Default::default()).unwrap();
+            CanonicalFSM::new(tpuzzle.clone(), generators.clone(), Default::default());
         Self {
             generators,
             canonical_fsm,


Reviews:
user: login: lgarron
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T21:48:43Z
user: login: lgarron
body: Alright, perf also looks good on my computer. I'd like to fix up a few things, but definitely worth landing.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T22:19:05Z
user: login: lgarron
body: Ideally, I think we should find a way to avoid needing to call `.zip` outside the `MoveClassMask` implementation.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-18T22:19:50Z
user: login: lgarron
body: Lookin' fab, thank you! 🤩
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T01:06:32Z

Review comments:
diff_hunk: @@ -8,30 +8,33 @@ use cubing::alg::QuantumMove;
 
 use crate::{
     _internal::{
-        errors::SearchError, puzzle_traits::puzzle_traits::SemiGroupActionPuzzle,
-        search::indexed_vec::IndexedVec,
+        puzzle_traits::puzzle_traits::SemiGroupActionPuzzle, search::indexed_vec::IndexedVec,
     },
     whole_number_newtype,
 };
 
 use super::search_generators::{MoveTransformationInfo, SearchGenerators};
 
-const MAX_NUM_MOVE_CLASSES: usize = usize::BITS as usize;
-
 whole_number_newtype!(MoveClassIndex, usize);
 
 // Bit N is indexed by a `MoveClassIndex` value of N.
-whole_number_newtype!(MoveClassMask, u64);
+// whole_number_newtype!(MoveClassMask, u64);
path: src/rs/_internal/canonical_fsm/canonical_fsm.rs
user: login: lgarron
body: ```suggestion
```
created_at: 2025-03-18T21:48:43Z
author_association: MEMBER
diff_hunk: @@ -160,65 +150,77 @@ impl<TPuzzle: SemiGroupActionPuzzle> CanonicalFSM<TPuzzle> {
         > = IndexedVec::default();
 
         let mut mask_to_state = MaskToState::default();
-        mask_to_state.insert(MoveClassMask(0), CANONICAL_FSM_START_STATE);
-        let mut state_to_mask = StateToMask::new(MoveClassMask(0));
+        mask_to_state.insert(
+            MoveClassMask(vec![false; num_move_classes]),
+            CANONICAL_FSM_START_STATE,
+        );
         // state_to_mask, indexed by state ordinal,  holds the set of move classes in the
         // move sequence so far for which there has not been a subsequent move that does not
         // commute with that move.
-        let mut disallowed_move_classes = StateToMask::new(MoveClassMask(0));
+        let mut state_to_mask = StateToMask::new(MoveClassMask(vec![false; num_move_classes]));
 
         let mut queue_index: CanonicalFSMState = CANONICAL_FSM_START_STATE;
-        while Into::<usize>::into(queue_index) < state_to_mask.0.len() {
+        while usize::from(queue_index) < state_to_mask.0.len() {
             let mut next_state: IndexedVec<MoveClassIndex, CanonicalFSMState> =
                 IndexedVec::new(vec![ILLEGAL_FSM_STATE; num_move_classes]);
 
-            let dequeue_move_class_mask: MoveClassMask = state_to_mask.get(queue_index);
-            disallowed_move_classes.push(MoveClassMask(0));
+            let dequeue_move_class_mask = state_to_mask.get(queue_index).clone();
 
             queue_index += CanonicalFSMState(1);
-            let from_state = queue_index;
 
             for move_class_index in generators.by_move_class.index_iter() {
                 let mut skip = false;
                 // If there's a greater move (multiple) in the state that
                 // commutes with this move's `move_class`, we can't move
                 // `move_class`.
-                skip |= (*dequeue_move_class_mask & *commutes[*move_class_index])
-                    >> (*move_class_index + 1)
-                    != 0;
-                skip |= (*dequeue_move_class_mask & *forbidden_transitions[*move_class_index]) != 0;
-                skip |= ((*dequeue_move_class_mask >> *move_class_index) & 1) != 0;
+                skip |= dequeue_move_class_mask
+                    .0
+                    .iter()
+                    .zip(commutes[*move_class_index].0.iter())
+                    .skip(*move_class_index + 1)
+                    .any(|(&dequeue_move_class, &commute)| dequeue_move_class && commute);
+                skip |= dequeue_move_class_mask
+                    .0
+                    .iter()
+                    .zip(forbidden_transitions[*move_class_index].0.iter())
+                    .any(|(&dequeue_move_class, &forbidden_transition)| {
+                        dequeue_move_class && forbidden_transition
+                    });
+                skip |= dequeue_move_class_mask.0[*move_class_index];
                 if skip {
-                    let new_value = MoveClassMask(
-                        *disallowed_move_classes.get(from_state) | (1 << *move_class_index),
-                    );
-                    disallowed_move_classes.set(from_state, new_value);
                     continue;
                 }
-                // TODO implement bit arithmetic for whole number newtypes.
-                let mut next_state_bits = (*dequeue_move_class_mask & *commutes[*move_class_index])
-                    | (1 << *move_class_index);
+                let mut next_state_mask = dequeue_move_class_mask.0.clone();
path: src/rs/_internal/canonical_fsm/canonical_fsm.rs
user: login: lgarron
body: If this is going to have this name, I think it should be a `MoveClassMask`. (We might also want to implement `.clone()` for `MoveClassMask` directly.)
```suggestion
                let mut next_state_mask = MoveClassMask(dequeue_move_class_mask.0.clone());
```
created_at: 2025-03-18T22:00:26Z
author_association: MEMBER
diff_hunk: @@ -160,65 +150,77 @@ impl<TPuzzle: SemiGroupActionPuzzle> CanonicalFSM<TPuzzle> {
         > = IndexedVec::default();
 
         let mut mask_to_state = MaskToState::default();
-        mask_to_state.insert(MoveClassMask(0), CANONICAL_FSM_START_STATE);
-        let mut state_to_mask = StateToMask::new(MoveClassMask(0));
+        mask_to_state.insert(
+            MoveClassMask(vec![false; num_move_classes]),
+            CANONICAL_FSM_START_STATE,
+        );
         // state_to_mask, indexed by state ordinal,  holds the set of move classes in the
         // move sequence so far for which there has not been a subsequent move that does not
         // commute with that move.
-        let mut disallowed_move_classes = StateToMask::new(MoveClassMask(0));
+        let mut state_to_mask = StateToMask::new(MoveClassMask(vec![false; num_move_classes]));
 
         let mut queue_index: CanonicalFSMState = CANONICAL_FSM_START_STATE;
-        while Into::<usize>::into(queue_index) < state_to_mask.0.len() {
+        while usize::from(queue_index) < state_to_mask.0.len() {
             let mut next_state: IndexedVec<MoveClassIndex, CanonicalFSMState> =
                 IndexedVec::new(vec![ILLEGAL_FSM_STATE; num_move_classes]);
 
-            let dequeue_move_class_mask: MoveClassMask = state_to_mask.get(queue_index);
-            disallowed_move_classes.push(MoveClassMask(0));
+            let dequeue_move_class_mask = state_to_mask.get(queue_index).clone();
 
             queue_index += CanonicalFSMState(1);
-            let from_state = queue_index;
 
             for move_class_index in generators.by_move_class.index_iter() {
                 let mut skip = false;
                 // If there's a greater move (multiple) in the state that
                 // commutes with this move's `move_class`, we can't move
                 // `move_class`.
-                skip |= (*dequeue_move_class_mask & *commutes[*move_class_index])
-                    >> (*move_class_index + 1)
-                    != 0;
-                skip |= (*dequeue_move_class_mask & *forbidden_transitions[*move_class_index]) != 0;
-                skip |= ((*dequeue_move_class_mask >> *move_class_index) & 1) != 0;
+                skip |= dequeue_move_class_mask
+                    .0
+                    .iter()
+                    .zip(commutes[*move_class_index].0.iter())
+                    .skip(*move_class_index + 1)
+                    .any(|(&dequeue_move_class, &commute)| dequeue_move_class && commute);
path: src/rs/_internal/canonical_fsm/canonical_fsm.rs
user: login: lgarron
body: This is a bit hard to read at a glance, do you think we could do it with a method on `MoveClassMask`?
created_at: 2025-03-18T22:17:17Z
author_association: MEMBER
diff_hunk: @@ -160,65 +150,77 @@ impl<TPuzzle: SemiGroupActionPuzzle> CanonicalFSM<TPuzzle> {
         > = IndexedVec::default();
 
         let mut mask_to_state = MaskToState::default();
-        mask_to_state.insert(MoveClassMask(0), CANONICAL_FSM_START_STATE);
-        let mut state_to_mask = StateToMask::new(MoveClassMask(0));
+        mask_to_state.insert(
+            MoveClassMask(vec![false; num_move_classes]),
+            CANONICAL_FSM_START_STATE,
+        );
         // state_to_mask, indexed by state ordinal,  holds the set of move classes in the
         // move sequence so far for which there has not been a subsequent move that does not
         // commute with that move.
-        let mut disallowed_move_classes = StateToMask::new(MoveClassMask(0));
+        let mut state_to_mask = StateToMask::new(MoveClassMask(vec![false; num_move_classes]));
 
         let mut queue_index: CanonicalFSMState = CANONICAL_FSM_START_STATE;
-        while Into::<usize>::into(queue_index) < state_to_mask.0.len() {
+        while usize::from(queue_index) < state_to_mask.0.len() {
             let mut next_state: IndexedVec<MoveClassIndex, CanonicalFSMState> =
                 IndexedVec::new(vec![ILLEGAL_FSM_STATE; num_move_classes]);
 
-            let dequeue_move_class_mask: MoveClassMask = state_to_mask.get(queue_index);
-            disallowed_move_classes.push(MoveClassMask(0));
+            let dequeue_move_class_mask = state_to_mask.get(queue_index).clone();
 
             queue_index += CanonicalFSMState(1);
-            let from_state = queue_index;
 
             for move_class_index in generators.by_move_class.index_iter() {
                 let mut skip = false;
                 // If there's a greater move (multiple) in the state that
                 // commutes with this move's `move_class`, we can't move
                 // `move_class`.
-                skip |= (*dequeue_move_class_mask & *commutes[*move_class_index])
-                    >> (*move_class_index + 1)
-                    != 0;
-                skip |= (*dequeue_move_class_mask & *forbidden_transitions[*move_class_index]) != 0;
-                skip |= ((*dequeue_move_class_mask >> *move_class_index) & 1) != 0;
+                skip |= dequeue_move_class_mask
+                    .0
+                    .iter()
+                    .zip(commutes[*move_class_index].0.iter())
+                    .skip(*move_class_index + 1)
+                    .any(|(&dequeue_move_class, &commute)| dequeue_move_class && commute);
+                skip |= dequeue_move_class_mask
+                    .0
+                    .iter()
+                    .zip(forbidden_transitions[*move_class_index].0.iter())
+                    .any(|(&dequeue_move_class, &forbidden_transition)| {
+                        dequeue_move_class && forbidden_transition
+                    });
+                skip |= dequeue_move_class_mask.0[*move_class_index];
                 if skip {
-                    let new_value = MoveClassMask(
-                        *disallowed_move_classes.get(from_state) | (1 << *move_class_index),
-                    );
-                    disallowed_move_classes.set(from_state, new_value);
                     continue;
                 }
-                // TODO implement bit arithmetic for whole number newtypes.
-                let mut next_state_bits = (*dequeue_move_class_mask & *commutes[*move_class_index])
-                    | (1 << *move_class_index);
+                let mut next_state_mask = dequeue_move_class_mask.0.clone();
+                for (next_state, commute) in next_state_mask
+                    .iter_mut()
+                    .zip(&commutes[*move_class_index].0)
+                {
+                    // TODO implement bit arithmetic for whole number newtypes.
path: src/rs/_internal/canonical_fsm/canonical_fsm.rs
user: login: lgarron
body: I think this should be able to use a simple `|=` if `next_state_mask` is a `MoveClassMask `?
created_at: 2025-03-18T22:18:37Z
author_association: MEMBER

PR comments:
user: login: lgarron
created_at: 2025-03-18T02:32:30Z
body: Hmm, it seems there are a lot of merge conflicts. Would you be able to resolve those?
author_association: MEMBER
user: login: lgarron
created_at: 2025-03-18T02:33:13Z
body: (The basic approach looks good. At some point I want to look at whether there is a small bit vector crate worth using, but it might not be worth it.)
author_association: MEMBER
user: login: lgarron
created_at: 2025-03-18T02:34:11Z
body: Could I also ask you to run `make benchmark-rust` with and without this change so we know the impact?
author_association: MEMBER
user: login: ArhanChaudhary
created_at: 2025-03-18T08:07:24Z
body: Oops, somehow forgot to pull from origin 😛

Before:

Took 3.549387709s for 100000000 transformations (28.17M moves/s)
Took 3.54443075s for 100000000 transformations (28.21M moves/s)
Took 3.564861584s for 100000000 transformations (28.05M moves/s)

After:

Took 3.573755292s for 100000000 transformations (27.98M moves/s)
Took 3.575061208s for 100000000 transformations (27.97M moves/s)
Took 3.573692916s for 100000000 transformations (27.98M moves/s)

This PR is entirely untested (as I don't really know how to test it) so I think it would be worth some basic sanity testing
author_association: CONTRIBUTOR
user: login: ArhanChaudhary
created_at: 2025-03-18T08:09:34Z
body: The canonical FSM is initialized O(1) times so I would expect the benchmarks to only differ statistically insignificantly and I don't think it's important to squeeze out performance from it
author_association: CONTRIBUTOR
user: login: lgarron
created_at: 2025-03-18T22:21:33Z
body: > The canonical FSM is initialized O(1) times so I would expect the benchmarks to only differ statistically insignificantly and I don't think it's important to squeeze out performance from it

Yeah, that should be the case but I wasn't 100% sure. Looks in order.
author_association: MEMBER
user: login: ArhanChaudhary
created_at: 2025-03-18T22:44:22Z
body: Okay, what about now?

I would document it a bit more but I truthfully don't really understand how the implementation works. I'm kinda just transcribing the code without much thought.
author_association: CONTRIBUTOR
user: login: lgarron
created_at: 2025-03-19T00:07:30Z
body: So, there seems to be a correctness issue:

```
╭─── /Users/lgarron/Code/jj/github.com/cubing/twsearch ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
├─ (jj) @=yv #=947 (empty) 
├─ lgarron@Germain
│ cargo run --release -- scramble 222
┴
    Finished `release` profile [optimized] target(s) in 0.06s
     Running `target/release/twsearch scramble 222`

thread 'main' panicked at src/rs/scramble/solving_based_scramble_finder.rs:62:18:
called `Result::unwrap()` on an `Err` value: SearchError { description: "Could not solve pattern" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
┬
├─ ❌ [101] command status
╰─── ⏱️ 0.364s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
```

I'm still combing through to figure out where exactly.
author_association: MEMBER
user: login: ArhanChaudhary
created_at: 2025-03-19T00:31:35Z
body: That's odd. I spent a good 15 minutes cross-checking with the original implementation and everything seems to be fine. Not sure what's happening, will take a closer look
author_association: CONTRIBUTOR
user: login: ArhanChaudhary
created_at: 2025-03-19T00:37:46Z
body: Found it! just made a small typo
author_association: CONTRIBUTOR