Skip to main content

quantik_core/
symmetry.rs

1use crate::bitboard::Bitboard;
2use std::sync::OnceLock;
3
4/// Position mapping: for each of the 8 D4 symmetries, maps input position → output position.
5type PosMap = [u8; 16];
6
7const D4_MAPS: [PosMap; 8] = {
8    let mut maps = [[0u8; 16]; 8];
9    let mut i: u8 = 0;
10    while i < 16 {
11        let r = i / 4;
12        let c = i % 4;
13
14        maps[0][i as usize] = r * 4 + c; // id
15        maps[1][i as usize] = c * 4 + (3 - r); // rot90
16        maps[2][i as usize] = (3 - r) * 4 + (3 - c); // rot180
17        maps[3][i as usize] = (3 - c) * 4 + r; // rot270
18        maps[4][i as usize] = r * 4 + (3 - c); // reflV
19        maps[5][i as usize] = (3 - r) * 4 + c; // reflH
20        maps[6][i as usize] = c * 4 + r; // reflD
21        maps[7][i as usize] = (3 - c) * 4 + (3 - r); // reflAD
22
23        i += 1;
24    }
25    maps
26};
27
28/// All 24 permutations of shapes 0..3.
29const SHAPE_PERMS: [[u8; 4]; 24] = generate_shape_perms();
30
31const fn generate_shape_perms() -> [[u8; 4]; 24] {
32    let mut perms = [[0u8; 4]; 24];
33    let mut idx = 0;
34    let mut a: u8 = 0;
35    while a < 4 {
36        let mut b: u8 = 0;
37        while b < 4 {
38            if b == a {
39                b += 1;
40                continue;
41            }
42            let mut c: u8 = 0;
43            while c < 4 {
44                if c == a || c == b {
45                    c += 1;
46                    continue;
47                }
48                let d = 6 - a - b - c; // the remaining element (0+1+2+3=6)
49                perms[idx] = [a, b, c, d];
50                idx += 1;
51                c += 1;
52            }
53            b += 1;
54        }
55        a += 1;
56    }
57    perms
58}
59
60/// Pre-computed LUT: `PERM16_LUT[d4_idx][mask]` → permuted mask.
61///
62/// Built once on first access (~1 MB).  We use `Vec` instead of a fixed
63/// array to avoid blowing the stack during initialisation.
64struct Perm16Lut {
65    tables: Vec<Vec<u16>>, // [8][65536]
66}
67
68fn build_perm16_lut() -> Perm16Lut {
69    let mut tables: Vec<Vec<u16>> = Vec::with_capacity(8);
70    for map in &D4_MAPS {
71        let mut t = vec![0u16; 65536];
72        for x in 0u32..65536 {
73            let mut y = 0u16;
74            let mut m = x as u16;
75            let mut i = 0u8;
76            while m != 0 {
77                if m & 1 != 0 {
78                    y |= 1u16 << map[i as usize];
79                }
80                i += 1;
81                m >>= 1;
82            }
83            t[x as usize] = y;
84        }
85        tables.push(t);
86    }
87    Perm16Lut { tables }
88}
89
90static PERM16_LUT: OnceLock<Perm16Lut> = OnceLock::new();
91
92fn lut() -> &'static Perm16Lut {
93    PERM16_LUT.get_or_init(build_perm16_lut)
94}
95
96#[inline]
97fn permute16(mask: u16, d4_idx: usize) -> u16 {
98    lut().tables[d4_idx][mask as usize]
99}
100
101pub struct SymmetryHandler;
102
103impl SymmetryHandler {
104    /// Find the canonical (lexicographically smallest) bitboard under the
105    /// 192-element symmetry group (8 D4 × 24 shape permutations, no color swap).
106    pub fn find_canonical(bb: &Bitboard) -> Bitboard {
107        let mut best: Option<[u16; 8]> = None;
108
109        for d4_idx in 0..8 {
110            let g0: [u16; 4] = std::array::from_fn(|s| permute16(bb.planes[s], d4_idx));
111            let g1: [u16; 4] = std::array::from_fn(|s| permute16(bb.planes[s + 4], d4_idx));
112
113            for perm in &SHAPE_PERMS {
114                let candidate: [u16; 8] = [
115                    g0[perm[0] as usize],
116                    g0[perm[1] as usize],
117                    g0[perm[2] as usize],
118                    g0[perm[3] as usize],
119                    g1[perm[0] as usize],
120                    g1[perm[1] as usize],
121                    g1[perm[2] as usize],
122                    g1[perm[3] as usize],
123                ];
124
125                let is_better = match &best {
126                    None => true,
127                    Some(b) => le_bytes_less(&candidate, b),
128                };
129                if is_better {
130                    best = Some(candidate);
131                }
132            }
133        }
134        Bitboard::new(best.unwrap_or([0; 8]))
135    }
136
137    /// 16-byte canonical payload (LE-packed planes of the canonical form).
138    pub fn canonical_payload(bb: &Bitboard) -> [u8; 16] {
139        Self::find_canonical(bb).to_le_bytes()
140    }
141
142    /// How many distinct boards in this orbit (1..192).
143    pub fn orbit_size(bb: &Bitboard) -> usize {
144        let mut seen = std::collections::HashSet::new();
145
146        for d4_idx in 0..8 {
147            let g0: [u16; 4] = std::array::from_fn(|s| permute16(bb.planes[s], d4_idx));
148            let g1: [u16; 4] = std::array::from_fn(|s| permute16(bb.planes[s + 4], d4_idx));
149
150            for perm in &SHAPE_PERMS {
151                let candidate: [u16; 8] = [
152                    g0[perm[0] as usize],
153                    g0[perm[1] as usize],
154                    g0[perm[2] as usize],
155                    g0[perm[3] as usize],
156                    g1[perm[0] as usize],
157                    g1[perm[1] as usize],
158                    g1[perm[2] as usize],
159                    g1[perm[3] as usize],
160                ];
161                seen.insert(candidate);
162            }
163        }
164        seen.len()
165    }
166}
167
168/// Compare two `[u16; 8]` in little-endian byte order.
169fn le_bytes_less(a: &[u16; 8], b: &[u16; 8]) -> bool {
170    for i in 0..8 {
171        let ab = a[i].to_le_bytes();
172        let bb = b[i].to_le_bytes();
173        for j in 0..2 {
174            match ab[j].cmp(&bb[j]) {
175                std::cmp::Ordering::Less => return true,
176                std::cmp::Ordering::Greater => return false,
177                std::cmp::Ordering::Equal => {}
178            }
179        }
180    }
181    false // equal
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn identity_permutation() {
190        assert_eq!(permute16(1, 0), 1); // identity
191    }
192
193    #[test]
194    fn canonical_empty_is_empty() {
195        let canon = SymmetryHandler::find_canonical(&Bitboard::EMPTY);
196        assert_eq!(canon, Bitboard::EMPTY);
197    }
198
199    #[test]
200    fn canonical_is_idempotent() {
201        let bb = Bitboard::EMPTY.with_move(0, 0, 0).with_move(1, 1, 5);
202        let c1 = SymmetryHandler::find_canonical(&bb);
203        let c2 = SymmetryHandler::find_canonical(&c1);
204        assert_eq!(c1, c2);
205    }
206
207    #[test]
208    fn rotated_boards_share_canonical_form() {
209        // Place shape A (player 0) at the four corners – each is a rotation of the other.
210        let corners = [0u8, 3, 12, 15];
211        let canonicals: Vec<Bitboard> = corners
212            .iter()
213            .map(|&pos| {
214                let bb = Bitboard::EMPTY.with_move(0, 0, pos);
215                SymmetryHandler::find_canonical(&bb)
216            })
217            .collect();
218        assert!(canonicals.windows(2).all(|w| w[0] == w[1]));
219    }
220
221    #[test]
222    fn orbit_size_single_piece() {
223        let bb = Bitboard::EMPTY.with_move(0, 0, 0);
224        let size = SymmetryHandler::orbit_size(&bb);
225        // A single piece at a corner: 4 corners × 4 shape relabellings = 16
226        assert_eq!(size, 16);
227    }
228
229    #[test]
230    fn shape_perms_count() {
231        assert_eq!(SHAPE_PERMS.len(), 24);
232    }
233
234    #[test]
235    fn d4_maps_are_permutations() {
236        for map in &D4_MAPS {
237            let mut sorted = map.to_vec();
238            sorted.sort();
239            let expected: Vec<u8> = (0..16).collect();
240            assert_eq!(sorted, expected);
241        }
242    }
243}