cube-model 1.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: cube-model
3
+ Version: 1.0.3
4
+ Summary: Orientation-independent cube model in strictly typed Python
5
+ Author: Yitzchak Gale
6
+ Author-email: gale@sefer.org
7
+ Requires-Python: >=3.13,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Project-URL: Homepage, https://github.com/ygale/cube-model
12
+ Project-URL: Repository, https://github.com/ygale/cube-model
13
+ Description-Content-Type: text/markdown
14
+
15
+ # cube-model
16
+
17
+ An orientation-independent cube model in strictly typed Python.
18
+
19
+ ## Highlights
20
+
21
+ - Strict typing (`mypy --strict`)
22
+ - Orientation-independent cube state
23
+ - `Color` and `Side` are enums
24
+ - `CornerSticker` objects form circular linked lists of size 3
25
+ - `EdgeSticker` objects form circular linked lists of size 2
26
+ - `Cube.next_edge` and `Cube.next_corner` encode clockwise sticker order on faces
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install cube-model
32
+ ```
33
+
34
+ ## Example
35
+
36
+ ```python
37
+ from cube_model import Color, Move, Multiplicity, Side, solved, move
38
+
39
+ cube = solved()
40
+ assert cube.front_color is Color.GREEN
41
+
42
+ move(Move(Side.FRONT, Multiplicity.CW), cube)
43
+ ```
44
+
45
+
@@ -0,0 +1,30 @@
1
+ # cube-model
2
+
3
+ An orientation-independent cube model in strictly typed Python.
4
+
5
+ ## Highlights
6
+
7
+ - Strict typing (`mypy --strict`)
8
+ - Orientation-independent cube state
9
+ - `Color` and `Side` are enums
10
+ - `CornerSticker` objects form circular linked lists of size 3
11
+ - `EdgeSticker` objects form circular linked lists of size 2
12
+ - `Cube.next_edge` and `Cube.next_corner` encode clockwise sticker order on faces
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install cube-model
18
+ ```
19
+
20
+ ## Example
21
+
22
+ ```python
23
+ from cube_model import Color, Move, Multiplicity, Side, solved, move
24
+
25
+ cube = solved()
26
+ assert cube.front_color is Color.GREEN
27
+
28
+ move(Move(Side.FRONT, Multiplicity.CW), cube)
29
+ ```
30
+
@@ -0,0 +1,28 @@
1
+ [tool.poetry]
2
+ name = "cube-model"
3
+ version = "1.0.3"
4
+ description = "Orientation-independent cube model in strictly typed Python"
5
+ authors = ["Yitzchak Gale <gale@sefer.org>"]
6
+ readme = "README.md"
7
+ packages = [{ include = "cube_model", from = "src" }]
8
+ include = [{ path = "src/cube_model/py.typed", format = ["sdist", "wheel"] }]
9
+ homepage = "https://github.com/ygale/cube-model"
10
+ repository = "https://github.com/ygale/cube-model"
11
+
12
+ [tool.poetry.dependencies]
13
+ python = ">=3.13,<4.0"
14
+
15
+ [tool.poetry.group.dev.dependencies]
16
+ pytest = ">=8.0"
17
+
18
+ [tool.pytest.ini_options]
19
+ testpaths = ["tests"]
20
+ pythonpath = ["src", "tests"]
21
+
22
+ [build-system]
23
+ requires = ["poetry-core>=1.9.0"]
24
+ build-backend = "poetry.core.masonry.api"
25
+
26
+ [tool.mypy]
27
+ mypy_path = ["src", "tests"]
28
+ explicit_package_bases = true
@@ -0,0 +1,86 @@
1
+ from .new_cube import new_cube, shuffled, solved
2
+ from .model import (
3
+ Color,
4
+ Side,
5
+ Sticker,
6
+ CornerSticker,
7
+ EdgeSticker,
8
+ Cube,
9
+ )
10
+
11
+ from .navigation import (
12
+ Nav,
13
+ nav,
14
+ nav_cc,
15
+ parse_navs,
16
+ HOME_TO_SIDE,
17
+ corner_on_side,
18
+ side_corners,
19
+ sticker_side,
20
+ side_color,
21
+ color_side,
22
+ all_colors,
23
+ )
24
+
25
+ from .move import (
26
+ Multiplicity,
27
+ Move,
28
+ move,
29
+ moved,
30
+ )
31
+
32
+ from .utils import (
33
+ permutation_cycles,
34
+ even_permutation,
35
+ rand_elt,
36
+ to_radix,
37
+ )
38
+
39
+ from .reachable import (
40
+ locations_ok,
41
+ edge_flips_ok,
42
+ corner_rotations_ok,
43
+ reachable,
44
+ )
45
+
46
+ from .rotate import (
47
+ rotate,
48
+ rotated,
49
+ )
50
+
51
+ __all__ = [
52
+ 'Color',
53
+ 'Side',
54
+ 'Sticker',
55
+ 'CornerSticker',
56
+ 'EdgeSticker',
57
+ 'Cube',
58
+ 'new_cube',
59
+ 'shuffled',
60
+ 'solved',
61
+ 'Nav',
62
+ 'nav',
63
+ 'nav_cc',
64
+ 'parse_navs',
65
+ 'HOME_TO_SIDE',
66
+ 'corner_on_side',
67
+ 'side_corners',
68
+ 'sticker_side',
69
+ 'side_color',
70
+ 'color_side',
71
+ 'all_colors',
72
+ 'Multiplicity',
73
+ 'Move',
74
+ 'move',
75
+ 'moved',
76
+ 'permutation_cycles',
77
+ 'even_permutation',
78
+ 'rand_elt',
79
+ 'to_radix',
80
+ 'locations_ok',
81
+ 'edge_flips_ok',
82
+ 'corner_rotations_ok',
83
+ 'reachable',
84
+ 'rotate',
85
+ 'rotated',
86
+ ]
@@ -0,0 +1,189 @@
1
+ '''Standard cube move notation: actions, parsing, and dispatch.
2
+
3
+ Action is the union Move | Rotation | WideMove | SliceMove.
4
+ Rotation, WideMove, and SliceMove are frozen dataclasses wrapping a
5
+ Move, distinct concrete types at runtime while serving as newtypes of
6
+ Move at the type-checker level.
7
+
8
+ act(action, cube) applies an action in place.
9
+ acted(action, cube) returns a new cube with the action applied.
10
+
11
+ Decompositions:
12
+ WideMove(m) — rotate(m, cube); move(Move(opp_side[m.face], m.mult), cube)
13
+ SliceMove(m) — rotate(m, cube); move(Move(opp_side[m.face], m.mult), cube);
14
+ move(Move(m.face, invert[m.mult]), cube)
15
+
16
+ parse_actions(s, ci=False) parses standard cube move notation.
17
+ Tokens may be written with or without spaces between them. Each token
18
+ is a base letter followed by an optional modifier: nothing (CW),
19
+ apostrophe (CCW), or 2 (TWO).
20
+
21
+ Standard notation:
22
+ Face moves: U D F B L R
23
+ Rotations: x y z
24
+ Wide moves: u d f b l r or Uw Dw Fw Bw Lw Rw
25
+ Slice moves: M E S
26
+
27
+ When ci=True, all letters are folded to uppercase. Bare lowercase
28
+ letters parse as face moves. Wide moves require the w or W suffix.
29
+ '''
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Iterator
34
+ from dataclasses import dataclass
35
+
36
+ from .model import Cube, Side, opp_side, shallow_copy
37
+ from .move import Move, Multiplicity, invert, move
38
+ from .rotate import rotate
39
+
40
+ @dataclass(frozen=True)
41
+ class Rotation:
42
+ '''A rigid cube rotation (x, y, z); newtype of Move.'''
43
+ move: Move
44
+
45
+ @dataclass(frozen=True)
46
+ class WideMove:
47
+ '''A wide two-layer move; newtype of Move.'''
48
+ move: Move
49
+
50
+ @dataclass(frozen=True)
51
+ class SliceMove:
52
+ '''A middle-layer slice move (M, E, S); newtype of Move.'''
53
+ move: Move
54
+
55
+ # Any action representable in standard cube notation.
56
+ type Action = Move | Rotation | WideMove | SliceMove
57
+
58
+ # An iterator of Action values.
59
+ type Actions = Iterator[Action]
60
+
61
+ _MULT_SUFFIX: dict[str, Multiplicity] = {
62
+ '': Multiplicity.CW,
63
+ "'": Multiplicity.CCW,
64
+ '2': Multiplicity.TWO,
65
+ }
66
+
67
+ _FACE_SIDE: dict[str, Side] = {
68
+ 'U': Side.TOP,
69
+ 'D': Side.BOTTOM,
70
+ 'F': Side.FRONT,
71
+ 'B': Side.BACK,
72
+ 'L': Side.LEFT,
73
+ 'R': Side.RIGHT,
74
+ }
75
+
76
+ _ROTATION_SIDE: dict[str, Side] = {
77
+ 'x': Side.RIGHT,
78
+ 'y': Side.TOP,
79
+ 'z': Side.FRONT,
80
+ }
81
+
82
+ _WIDE_SIDE: dict[str, Side] = {
83
+ 'u': Side.TOP,
84
+ 'd': Side.BOTTOM,
85
+ 'f': Side.FRONT,
86
+ 'b': Side.BACK,
87
+ 'l': Side.LEFT,
88
+ 'r': Side.RIGHT,
89
+ }
90
+
91
+ _SLICE_SIDE: dict[str, Side] = {
92
+ 'M': Side.LEFT,
93
+ 'E': Side.BOTTOM,
94
+ 'S': Side.FRONT,
95
+ }
96
+
97
+ class ParseError(ValueError):
98
+ '''Raised when a move token cannot be parsed.'''
99
+
100
+ def _parse_mult(s: str, pos: int) -> tuple[Multiplicity, int]:
101
+ '''Parse a multiplicity modifier at pos, returning (mult, new_pos).'''
102
+ if pos < len(s) and s[pos] == "'":
103
+ return Multiplicity.CCW, pos + 1
104
+ if pos < len(s) and s[pos] == '2':
105
+ return Multiplicity.TWO, pos + 1
106
+ return Multiplicity.CW, pos
107
+
108
+ def _parse_one(s: str, pos: int, ci: bool) -> tuple[Action, int]:
109
+ '''Parse one token from s starting at pos.
110
+
111
+ Returns (action, new_pos). Raises ParseError on invalid input.
112
+ '''
113
+ if pos >= len(s):
114
+ raise ParseError(f'unexpected end of input at position {pos}')
115
+ base: str = s[pos]
116
+ pos += 1
117
+
118
+ # w/W suffix: wide move. Valid for any face letter, either case.
119
+ if pos < len(s) and s[pos] in ('w', 'W'):
120
+ pos += 1
121
+ mult, pos = _parse_mult(s, pos)
122
+ upper: str = base.upper()
123
+ if upper not in _FACE_SIDE:
124
+ raise ParseError(
125
+ f'unknown wide-move face {base!r}'
126
+ )
127
+ return WideMove(Move(_FACE_SIDE[upper], mult)), pos
128
+
129
+ if ci:
130
+ upper = base.upper()
131
+ mult, pos = _parse_mult(s, pos)
132
+ if upper in _FACE_SIDE:
133
+ return Move(_FACE_SIDE[upper], mult), pos
134
+ if upper in ('X', 'Y', 'Z'):
135
+ return Rotation(Move(_ROTATION_SIDE[base.lower()], mult)), pos
136
+ if upper in ('M', 'E', 'S'):
137
+ return SliceMove(Move(_SLICE_SIDE[upper], mult)), pos
138
+ raise ParseError(f'unknown move letter {base!r}')
139
+
140
+ mult, pos = _parse_mult(s, pos)
141
+ if base in _FACE_SIDE:
142
+ return Move(_FACE_SIDE[base], mult), pos
143
+ if base in _ROTATION_SIDE:
144
+ return Rotation(Move(_ROTATION_SIDE[base], mult)), pos
145
+ if base in _WIDE_SIDE:
146
+ return WideMove(Move(_WIDE_SIDE[base], mult)), pos
147
+ if base in _SLICE_SIDE:
148
+ return SliceMove(Move(_SLICE_SIDE[base], mult)), pos
149
+ raise ParseError(f'unknown move letter {base!r}')
150
+
151
+ def parse_actions(s: str, ci: bool = False) -> list[Action]:
152
+ '''Parse standard cube move notation into a list of actions.
153
+
154
+ Tokens may be separated by optional whitespace. Each token is a
155
+ base letter followed by an optional modifier: nothing (CW),
156
+ apostrophe (CCW), or 2 (TWO).
157
+
158
+ Raises ParseError on unrecognised letters or modifiers.
159
+ '''
160
+ actions: list[Action] = []
161
+ pos: int = 0
162
+ while pos < len(s):
163
+ if s[pos].isspace():
164
+ pos += 1
165
+ continue
166
+ action, pos = _parse_one(s, pos, ci)
167
+ actions.append(action)
168
+ return actions
169
+
170
+ def act(action: Action, cube: Cube) -> None:
171
+ '''Apply an action to a cube in place.'''
172
+ match action:
173
+ case Rotation(move=m):
174
+ rotate(m, cube)
175
+ case WideMove(move=m):
176
+ rotate(m, cube)
177
+ move(Move(opp_side[m.face], m.mult), cube)
178
+ case SliceMove(move=m):
179
+ rotate(m, cube)
180
+ move(Move(opp_side[m.face], m.mult), cube)
181
+ move(Move(m.face, invert[m.mult]), cube)
182
+ case Move() as m:
183
+ move(m, cube)
184
+
185
+ def acted(action: Action, cube: Cube) -> Cube:
186
+ '''Return a new cube with an action applied, leaving the original unchanged.'''
187
+ new: Cube = shallow_copy(cube)
188
+ act(action, new)
189
+ return new
@@ -0,0 +1,99 @@
1
+ '''Core cube model: colors, sides, stickers, and the Cube dataclass.'''
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum, auto
8
+
9
+ class Color(Enum):
10
+ '''Enumeration of the six cube colors.'''
11
+ WHITE = auto()
12
+ YELLOW = auto()
13
+ RED = auto()
14
+ ORANGE = auto()
15
+ BLUE = auto()
16
+ GREEN = auto()
17
+
18
+ class Side(Enum):
19
+ '''Enumeration of the six cube sides.'''
20
+ FRONT = auto()
21
+ BACK = auto()
22
+ LEFT = auto()
23
+ RIGHT = auto()
24
+ TOP = auto()
25
+ BOTTOM = auto()
26
+
27
+ @dataclass(eq=False)
28
+ class Sticker(ABC):
29
+ '''Abstract sticker with a color and cyclic partner.'''
30
+ color: Color
31
+ other: Sticker = field(init=False)
32
+ _hash: int = field(init=False, repr=False, compare=False)
33
+
34
+ def __post_init__(self) -> None:
35
+ '''Initialize as a self-loop before wiring.'''
36
+ self._rewire(self)
37
+
38
+ def _rewire(self, other: Sticker) -> None:
39
+ '''Set partner and recompute hash.'''
40
+ self.other = other
41
+ self._hash = hash((self.color, other.color))
42
+
43
+ def __eq__(self, other: object) -> bool:
44
+ '''Equality based on color pair.'''
45
+ if not isinstance(other, Sticker):
46
+ return NotImplemented
47
+ return (
48
+ self.color == other.color
49
+ and self.other.color == other.other.color
50
+ )
51
+
52
+ def __hash__(self) -> int:
53
+ '''Hash based on this sticker's color and its partner's color.'''
54
+ return self._hash
55
+
56
+ @dataclass(eq=False)
57
+ class CornerSticker(Sticker):
58
+ '''Corner sticker linked in a 3-cycle.'''
59
+ other: CornerSticker = field(init=False)
60
+
61
+ @dataclass(eq=False)
62
+ class EdgeSticker(Sticker):
63
+ '''Edge sticker linked in a 2-cycle.'''
64
+ other: EdgeSticker = field(init=False)
65
+
66
+ @dataclass
67
+ class Cube:
68
+ '''Cube defined by one corner and cyclic adjacency maps.'''
69
+ home: CornerSticker
70
+ front_color: Color
71
+ top_color: Color
72
+ next_edge: dict[CornerSticker, EdgeSticker]
73
+ next_corner: dict[EdgeSticker, CornerSticker]
74
+
75
+ # Opposite face for each side.
76
+ opp_side: dict[Side, Side] = {
77
+ Side.LEFT: Side.RIGHT,
78
+ Side.RIGHT: Side.LEFT,
79
+ Side.BOTTOM: Side.TOP,
80
+ Side.TOP: Side.BOTTOM,
81
+ Side.BACK: Side.FRONT,
82
+ Side.FRONT: Side.BACK,
83
+ }
84
+
85
+ def shallow_copy(cube: Cube) -> Cube:
86
+ '''Return a new Cube sharing all sticker objects but with new dicts.
87
+
88
+ Moves only reassign dict values (which sticker a key maps to); they
89
+ never mutate sticker objects themselves. A shallow copy therefore
90
+ gives full independence between the two cubes without allocating new
91
+ sticker objects.
92
+ '''
93
+ return Cube(
94
+ home=cube.home,
95
+ front_color=cube.front_color,
96
+ top_color=cube.top_color,
97
+ next_edge=dict(cube.next_edge),
98
+ next_corner=dict(cube.next_corner),
99
+ )
@@ -0,0 +1,167 @@
1
+ '''Face moves implemented via local sticker cycles.'''
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum, auto
7
+
8
+ from .model import CornerSticker, Cube, EdgeSticker, Side, shallow_copy
9
+ from .navigation import (
10
+ Nav,
11
+ nav_cc,
12
+ corner_on_side,
13
+ side_corners,
14
+ parse_navs,
15
+ )
16
+
17
+ class Multiplicity(Enum):
18
+ '''Move multiplicity.'''
19
+ CW = auto()
20
+ CCW = auto()
21
+ TWO = auto()
22
+
23
+ # Inverse multiplicity: CW<->CCW, TWO is self-inverse.
24
+ invert: dict[Multiplicity, Multiplicity] = {
25
+ Multiplicity.CW: Multiplicity.CCW,
26
+ Multiplicity.CCW: Multiplicity.CW,
27
+ Multiplicity.TWO: Multiplicity.TWO,
28
+ }
29
+
30
+ @dataclass(frozen=True)
31
+ class Move:
32
+ '''A face move: a side and a multiplicity.'''
33
+ face: Side
34
+ mult: Multiplicity
35
+
36
+ restore_home: dict[tuple[Side, Multiplicity], list[Nav]] = {
37
+ (Side.FRONT, Multiplicity.CW): parse_navs('ONNO'),
38
+ (Side.FRONT, Multiplicity.CCW): parse_navs('NN'),
39
+ (Side.FRONT, Multiplicity.TWO): parse_navs('NNNN'),
40
+
41
+ (Side.LEFT, Multiplicity.CW): parse_navs('OONN'),
42
+ (Side.LEFT, Multiplicity.CCW): parse_navs('OONNOO'),
43
+ (Side.LEFT, Multiplicity.TWO): parse_navs('ONNNNOO'),
44
+
45
+ (Side.TOP, Multiplicity.CW): parse_navs('NNOO'),
46
+ (Side.TOP, Multiplicity.CCW): parse_navs('OONNO'),
47
+ (Side.TOP, Multiplicity.TWO): parse_navs('OONNNNO'),
48
+ }
49
+
50
+ def _move_corner(
51
+ cube: Cube, corner: CornerSticker, m: Multiplicity
52
+ ) -> None:
53
+ '''Rotate the face of a corner with given multiplicity.
54
+
55
+ Note: caller may need to restore cube.home afterwards.
56
+ '''
57
+ corners: list[CornerSticker] = side_corners(cube, corner)
58
+
59
+ corners_oo: list[CornerSticker] = [
60
+ c.other.other for c in corners
61
+ ]
62
+
63
+ edges_no: list[EdgeSticker] = [
64
+ cube.next_edge[c].other for c in corners_oo
65
+ ]
66
+
67
+ match m:
68
+ case Multiplicity.CW:
69
+ (
70
+ cube.next_edge[corners_oo[0]],
71
+ cube.next_edge[corners_oo[1]],
72
+ cube.next_edge[corners_oo[2]],
73
+ cube.next_edge[corners_oo[3]],
74
+ ) = (
75
+ cube.next_edge[corners_oo[1]],
76
+ cube.next_edge[corners_oo[2]],
77
+ cube.next_edge[corners_oo[3]],
78
+ cube.next_edge[corners_oo[0]],
79
+ )
80
+
81
+ (
82
+ cube.next_corner[edges_no[0]],
83
+ cube.next_corner[edges_no[1]],
84
+ cube.next_corner[edges_no[2]],
85
+ cube.next_corner[edges_no[3]],
86
+ ) = (
87
+ cube.next_corner[edges_no[3]],
88
+ cube.next_corner[edges_no[0]],
89
+ cube.next_corner[edges_no[1]],
90
+ cube.next_corner[edges_no[2]],
91
+ )
92
+
93
+ case Multiplicity.CCW:
94
+ (
95
+ cube.next_edge[corners_oo[0]],
96
+ cube.next_edge[corners_oo[1]],
97
+ cube.next_edge[corners_oo[2]],
98
+ cube.next_edge[corners_oo[3]],
99
+ ) = (
100
+ cube.next_edge[corners_oo[3]],
101
+ cube.next_edge[corners_oo[0]],
102
+ cube.next_edge[corners_oo[1]],
103
+ cube.next_edge[corners_oo[2]],
104
+ )
105
+
106
+ (
107
+ cube.next_corner[edges_no[0]],
108
+ cube.next_corner[edges_no[1]],
109
+ cube.next_corner[edges_no[2]],
110
+ cube.next_corner[edges_no[3]],
111
+ ) = (
112
+ cube.next_corner[edges_no[1]],
113
+ cube.next_corner[edges_no[2]],
114
+ cube.next_corner[edges_no[3]],
115
+ cube.next_corner[edges_no[0]],
116
+ )
117
+
118
+ case Multiplicity.TWO:
119
+ (
120
+ cube.next_edge[corners_oo[0]],
121
+ cube.next_edge[corners_oo[2]],
122
+ ) = (
123
+ cube.next_edge[corners_oo[2]],
124
+ cube.next_edge[corners_oo[0]],
125
+ )
126
+
127
+ (
128
+ cube.next_edge[corners_oo[1]],
129
+ cube.next_edge[corners_oo[3]],
130
+ ) = (
131
+ cube.next_edge[corners_oo[3]],
132
+ cube.next_edge[corners_oo[1]],
133
+ )
134
+
135
+ (
136
+ cube.next_corner[edges_no[0]],
137
+ cube.next_corner[edges_no[2]],
138
+ ) = (
139
+ cube.next_corner[edges_no[2]],
140
+ cube.next_corner[edges_no[0]],
141
+ )
142
+
143
+ (
144
+ cube.next_corner[edges_no[1]],
145
+ cube.next_corner[edges_no[3]],
146
+ ) = (
147
+ cube.next_corner[edges_no[3]],
148
+ cube.next_corner[edges_no[1]],
149
+ )
150
+
151
+ def _move_home(cube: Cube, side: Side, m: Multiplicity) -> None:
152
+ '''Restore home pointer after a face move.'''
153
+ key: tuple[Side, Multiplicity] = (side, m)
154
+ if key in restore_home:
155
+ cube.home = nav_cc(restore_home[key], cube, cube.home)
156
+
157
+ def move(m: Move, cube: Cube) -> None:
158
+ '''Rotate a face with given multiplicity.'''
159
+ corner: CornerSticker = corner_on_side(cube, m.face)
160
+ _move_corner(cube, corner, m.mult)
161
+ _move_home(cube, m.face, m.mult)
162
+
163
+ def moved(m: Move, cube: Cube) -> Cube:
164
+ '''Return a new cube after rotating a face.'''
165
+ new: Cube = shallow_copy(cube)
166
+ move(m, new)
167
+ return new