From bdf52a238ca7a6ab7adccd77820edabd9b11223b Mon Sep 17 00:00:00 2001 From: tcbegley Date: Mon, 31 Aug 2026 08:15:26 +0100 Subject: [PATCH 1/6] Adopt uv and introduce immutable cube model --- .flake8 | 2 - .github/workflows/ci.yml | 29 - .github/workflows/test.yaml | 30 ++ .gitignore | 14 +- README.md | 76 +-- justfile | 13 + noxfile.py | 21 - poetry.lock | 7 - pyproject.toml | 42 +- src/twophase/__init__.py | 10 + src/twophase/constants.py | 51 ++ src/twophase/exceptions.py | 2 + {twophase => src/twophase}/pieces.py | 60 +++ src/twophase/py.typed | 0 src/twophase/tables/__init__.py | 5 + src/twophase/tables/cubiecube.py | 749 ++++++++++++++++++++++++++ twophase/__init__.py | 55 -- twophase/cubes/__init__.py | 5 - twophase/cubes/coordcube.py | 55 -- twophase/cubes/cubiecube.py | 767 --------------------------- twophase/cubes/facecube.py | 111 ---- twophase/random.py | 17 - twophase/solve.py | 255 --------- twophase/tables.py | 278 ---------- uv.lock | 151 ++++++ 25 files changed, 1116 insertions(+), 1689 deletions(-) delete mode 100644 .flake8 delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/test.yaml create mode 100644 justfile delete mode 100644 noxfile.py delete mode 100644 poetry.lock create mode 100644 src/twophase/__init__.py create mode 100644 src/twophase/constants.py create mode 100644 src/twophase/exceptions.py rename {twophase => src/twophase}/pieces.py (55%) create mode 100644 src/twophase/py.typed create mode 100644 src/twophase/tables/__init__.py create mode 100644 src/twophase/tables/cubiecube.py delete mode 100644 twophase/__init__.py delete mode 100644 twophase/cubes/__init__.py delete mode 100644 twophase/cubes/coordcube.py delete mode 100644 twophase/cubes/cubiecube.py delete mode 100644 twophase/cubes/facecube.py delete mode 100644 twophase/random.py delete mode 100644 twophase/solve.py delete mode 100644 twophase/tables.py create mode 100644 uv.lock diff --git a/.flake8 b/.flake8 deleted file mode 100644 index fe326c8..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -ignore = E203, E501, W503 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 1f2f1e0..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: CI Tests - -on: [push] - -jobs: - build: - runs-on: "ubuntu-latest" - strategy: - max-parallel: 4 - matrix: - python-version: [3.6, 3.7, 3.8] - - steps: - - uses: actions/checkout@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install nox - - name: Lint source - if: matrix.python-version == 3.8 - run: | - nox -s lint - - name: Check and build - run: | - nox -s build diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..50ba1e2 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,30 @@ +name: Tests + +on: [push] + +jobs: + lint-and-test: + runs-on: "ubuntu-latest" + strategy: + max-parallel: 4 + matrix: + python-version: ["3.12", "3.13"] + + steps: + - uses: actions/checkout@v3 + - name: Install just + uses: extractions/setup-just@v2 + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + - name: Cache generated tables + uses: actions/cache@v4 + with: + path: src/twophase/tables/.cache + key: tables-v1-${{ runner.os }}-${{ runner.arch }}-${{ matrix.python-version }}-${{ hashFiles('src/twophase/constants.py', 'src/twophase/pieces.py', 'src/twophase/tables/**/*.py') }} + - name: Lint code + if: matrix.python-version == '3.13' + run: just lint + - name: Run tests + run: just python_version=${{ matrix.python-version }} test diff --git a/.gitignore b/.gitignore index 4764406..c4eafe4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,10 @@ # python artifacts +dist __pycache__ +*.so +*.pyc *.egg-info +.ipynb_checkpoints -# build and test artifacts -.nox -dist - -# move and pruning tables -tables.json -# legacy, code no longer generates this -tables.pkl +# cached tables +*.bin diff --git a/README.md b/README.md index 91211f8..845ae4d 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,21 @@ -# cube-solver +# twophase -A pure Python implementation of Herbert Kociemba's two-phase algorithm for solving the Rubik's Cube +An educational Python implementation of Herbert Kociemba's two-phase algorithm +for solving a Rubik's Cube. -## Installation +The project models a cube as its movable corner and edge pieces, represents +states with compact coordinates, and uses generated move and pruning tables to +search for solutions. The implementation favors readable, well-tested Python +alongside the optimizations needed for a practical solver. -Requires Python 3. Install with +## Development -```sh -pip install git+https://github.com/tcbegley/cube-solver.git -``` - -Note that depending on how your system is configured, you may need to replace `pip` with `pip3` in the above command to install for Python 3. - -## Usage - -To solve a cube, just import the `solve` method and pass a cube string. - -```python -from twophase import solve - -solve("") -``` - -Where the cube string is a 54 character string, consisting of the characters U, R, F, D, L, B (corresponding to the Upper, Right, Front, Down, Left and Back faces). Each character corresponds to one of the 54 stickers on the cube: +This repository uses [uv](https://docs.astral.sh/uv/) to manage its Python +environment and lock file: -```plaintext - |------------| - |-U1--U2--U3-| - |------------| - |-U4--U5--U6-| - |------------| - |-U7--U8--U9-| -|------------|------------|------------|------------| -|-L1--L2--L3-|-F1--F2--F3-|-R1--R2--R3-|-B1--B2--B3-| -|------------|------------|------------|------------| -|-L4--L5--L6-|-F4--F5--F6-|-R4--R5--R6-|-B4--B5--B6-| -|------------|------------|------------|------------| -|-L7--L8--L9-|-F7--F8--F9-|-R7--R8--R9-|-B7--B8--B9-| -|------------|------------|------------|------------| - |-D1--D2--D3-| - |------------| - |-D4--D5--D6-| - |------------| - |-D7--D8--D9-| - |------------| +```sh +uv sync --all-groups +uv run pytest ``` -and should be specified in the order U1-U9, R1-R9, F1-F9, D1-D9, L1-L9, B1-B9. - -For example, a completely solved cube is represented by the string `"UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB"`. - -`solve` will return a solution unless timeout has been reached (default is 10 seconds). Typically it will find a solution very quickly unless you set a low upper bound on the number of moves allowed. Note that the first time you run `solve`, it will precompute move tables needed for the solution which might take ~1 minute. Subsequent runs will be much faster. - -If you want to keep searching for better solutions, use the `solve_best` or -`solve_best_generator` functions. `solve_best` reduces `max_length` each time a -solution is found and continues searching for a better solution. All solutions -found are returned in a list at the end. `solve_best_generator` creates a -generator that yields solutions as they are found. - -```python -from twophase import solve_best, solve_best_generator - -# returns a list of solutions -solve_best("") - -# creates a generator that yields solutions as they are found -solve_best_generator("") -``` +The public parsing and solving interface is introduced with the solver. diff --git a/justfile b/justfile new file mode 100644 index 0000000..06402d6 --- /dev/null +++ b/justfile @@ -0,0 +1,13 @@ +python_version := "3.12" + +lint: + uv run ruff check + uv run ruff format --check + uv run ty check + +format: + uv run ruff check --fix + uv run ruff format + +test: + uv run --python {{ python_version }} pytest tests/ diff --git a/noxfile.py b/noxfile.py deleted file mode 100644 index 973fa99..0000000 --- a/noxfile.py +++ /dev/null @@ -1,21 +0,0 @@ -import nox - -SOURCES = ["twophase", "noxfile.py"] - - -@nox.session() -def lint(session): - """Lint python source""" - session.install("black", "isort", "flake8") - session.run("black", "--check", *SOURCES) - session.run("isort", "--check", "--recursive", *SOURCES) - session.run("flake8", *SOURCES) - - -@nox.session() -def build(session): - """Check and build""" - session.install("poetry") - env = {"VIRTUAL_ENV": session.virtualenv.location} - session.run("poetry", "check", env=env, external=True) - session.run("poetry", "build", env=env, external=True) diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 34fdee5..0000000 --- a/poetry.lock +++ /dev/null @@ -1,7 +0,0 @@ -package = [] - -[metadata] -content-hash = "de7600acb56ad9a67b77fd08bb5acd96ae59d0bbc6d538e5682444b7d4b1cc16" -python-versions = "^3.6" - -[metadata.files] diff --git a/pyproject.toml b/pyproject.toml index ac44f6d..b628042 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,31 @@ [build-system] -requires = [ "poetry>=0.12",] -build-backend = "poetry.masonry.api" +requires = ["uv_build>=0.12.7,<0.13.0"] +build-backend = "uv_build" -[tool.poetry] +[project] name = "twophase" -version = "0.1.0" -description = "Rubik's cube solver implementing Kociemba's two-phase algorithm." -authors = [ "tcbegley ",] -license = "MIT" -classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: MIT License",] +version = "0.2.0" +description = "An educational implementation of Kociemba's two-phase cube-solving algorithm." +readme = "README.md" +requires-python = ">=3.12" +dependencies = ["tqdm>=4.66.5"] -[tool.poetry.dependencies] -python = "^3.6" +[dependency-groups] +dev = [ + "pytest>=8.4.0", + "ruff>=0.11.13", + "ty>=0.0.21", +] -[tool.black] -line-length = 79 -target-version = [ "py36" ] +[tool.ruff] +target-version = "py312" +line-length = 88 -[tool.isort] -include_trailing_comma = true -line_length = 79 -multi_line_output = 3 +[tool.ruff.format] +skip-magic-trailing-comma = true + +[tool.ruff.lint.isort] +split-on-trailing-comma = false + +[tool.ruff.lint] +select = ["F", "E", "W", "I001"] diff --git a/src/twophase/__init__.py b/src/twophase/__init__.py new file mode 100644 index 0000000..f1d8f0b --- /dev/null +++ b/src/twophase/__init__.py @@ -0,0 +1,10 @@ +"""An educational implementation of Kociemba's two-phase cube solver.""" + +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("twophase") +except PackageNotFoundError: + __version__ = "0.2.0+fallback" + +__all__ = ["__version__"] diff --git a/src/twophase/constants.py b/src/twophase/constants.py new file mode 100644 index 0000000..bf6a75d --- /dev/null +++ b/src/twophase/constants.py @@ -0,0 +1,51 @@ +import math + +from twophase.pieces import Move + +TWIST_MAX = 3**7 +FLIP_MAX = 2**11 +UD_SLICE_MAX = math.comb(12, 4) +EDGE4_MAX = 24 +EDGE8_MAX = 40_320 +CORNER_MAX = 40_320 + +MOVES = 18 +SYMMETRIES = 48 +SYMMETRIES_D4H = 16 +FLIP_UD_SLICE_CLASSES = 64430 +CORNER_CLASSES = 2768 + +PHASE_1_MOVES = [ + Move.U1, + Move.U2, + Move.U3, + Move.R1, + Move.R2, + Move.R3, + Move.F1, + Move.F2, + Move.F3, + Move.D1, + Move.D2, + Move.D3, + Move.L1, + Move.L2, + Move.L3, + Move.B1, + Move.B2, + Move.B3, +] +PHASE_2_PRUNE_UNFILLED_DEPTH = 11 + +PHASE_2_MOVES = [ + Move.U1, + Move.U2, + Move.U3, + Move.R2, + Move.F2, + Move.D1, + Move.D2, + Move.D3, + Move.L2, + Move.B2, +] diff --git a/src/twophase/exceptions.py b/src/twophase/exceptions.py new file mode 100644 index 0000000..867a7cc --- /dev/null +++ b/src/twophase/exceptions.py @@ -0,0 +1,2 @@ +class InvalidCube(ValueError): + pass diff --git a/twophase/pieces.py b/src/twophase/pieces.py similarity index 55% rename from twophase/pieces.py rename to src/twophase/pieces.py index b07fc03..1f7d798 100644 --- a/twophase/pieces.py +++ b/src/twophase/pieces.py @@ -1,7 +1,13 @@ +""" +Enums to represent different pieces of the cube. +""" + +from collections.abc import Iterable from enum import IntEnum class Color(IntEnum): + NULL = -1 U = 0 R = 1 F = 2 @@ -11,6 +17,7 @@ class Color(IntEnum): class Corner(IntEnum): + NULL = -1 URF = 0 UFL = 1 ULB = 2 @@ -22,6 +29,7 @@ class Corner(IntEnum): class Edge(IntEnum): + NULL = -1 UR = 0 UF = 1 UL = 2 @@ -37,6 +45,8 @@ class Edge(IntEnum): class Facelet(IntEnum): + NULL = -1 + U1 = 0 U2 = 1 U3 = 2 @@ -46,6 +56,7 @@ class Facelet(IntEnum): U7 = 6 U8 = 7 U9 = 8 + R1 = 9 R2 = 10 R3 = 11 @@ -55,6 +66,7 @@ class Facelet(IntEnum): R7 = 15 R8 = 16 R9 = 17 + F1 = 18 F2 = 19 F3 = 20 @@ -64,6 +76,7 @@ class Facelet(IntEnum): F7 = 24 F8 = 25 F9 = 26 + D1 = 27 D2 = 28 D3 = 29 @@ -73,6 +86,7 @@ class Facelet(IntEnum): D7 = 33 D8 = 34 D9 = 35 + L1 = 36 L2 = 37 L3 = 38 @@ -82,6 +96,7 @@ class Facelet(IntEnum): L7 = 42 L8 = 43 L9 = 44 + B1 = 45 B2 = 46 B3 = 47 @@ -91,3 +106,48 @@ class Facelet(IntEnum): B7 = 51 B8 = 52 B9 = 53 + + +_FACES = "URFDLB" +_SUFFIXES = ("", "2", "'") + + +class Move(IntEnum): + NULL = -1 + + U1 = 0 + U2 = 1 + U3 = 2 + + R1 = 3 + R2 = 4 + R3 = 5 + + F1 = 6 + F2 = 7 + F3 = 8 + + D1 = 9 + D2 = 10 + D3 = 11 + + L1 = 12 + L2 = 13 + L3 = 14 + + B1 = 15 + B2 = 16 + B3 = 17 + + def __str__(self) -> str: + """Standard cube notation: ``U``, ``R2``, ``F'``, etc.""" + return _FACES[self // 3] + _SUFFIXES[self % 3] + + +def format_moves(moves: Iterable[Move]) -> str: + """Format a sequence of moves as a human-readable string. + + >>> format_moves([Move.L3, Move.U3, Move.R2]) + "L' U' R2" + """ + return " ".join(str(m) for m in moves) diff --git a/src/twophase/py.typed b/src/twophase/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/twophase/tables/__init__.py b/src/twophase/tables/__init__.py new file mode 100644 index 0000000..76db90e --- /dev/null +++ b/src/twophase/tables/__init__.py @@ -0,0 +1,5 @@ +"""Cube model and, later, the lookup tables used by the solver.""" + +from twophase.tables.cubiecube import CubieCube, Moves, move, verify + +__all__ = ["CubieCube", "Moves", "move", "verify"] diff --git a/src/twophase/tables/cubiecube.py b/src/twophase/tables/cubiecube.py new file mode 100644 index 0000000..bb853df --- /dev/null +++ b/src/twophase/tables/cubiecube.py @@ -0,0 +1,749 @@ +from __future__ import annotations + +import functools +import math +from dataclasses import dataclass +from enum import IntEnum +from typing import Callable, Iterator + +from twophase.constants import ( + CORNER_MAX, + EDGE4_MAX, + EDGE8_MAX, + FLIP_MAX, + TWIST_MAX, + UD_SLICE_MAX, +) +from twophase.exceptions import InvalidCube +from twophase.pieces import Corner, Edge + +CORNER_PERM = tuple[Corner, ...] +PIECE_ORI = tuple[int, ...] +EDGE_PERM = tuple[Edge, ...] + +SOLVED_CP = ( + Corner.URF, + Corner.UFL, + Corner.ULB, + Corner.UBR, + Corner.DFR, + Corner.DLF, + Corner.DBL, + Corner.DRB, +) +SOLVED_CO = (0,) * 8 +SOLVED_EP = ( + Edge.UR, + Edge.UF, + Edge.UL, + Edge.UB, + Edge.DR, + Edge.DF, + Edge.DL, + Edge.DB, + Edge.FR, + Edge.FL, + Edge.BL, + Edge.BR, +) +SOLVED_EO = (0,) * 12 + +U_EDGES = [Edge.UR, Edge.UF, Edge.UL, Edge.UB] +UD_SLICE = [Edge.FR, Edge.FL, Edge.BL, Edge.BR] +D_EDGES = [Edge.DR, Edge.DF, Edge.DL, Edge.DB] + + +def _perm_to_coord(items: tuple[int | IntEnum, ...]) -> int: + # maps each possible permutation of items onto 0, ..., len(items)! - 1 + + # given a permutation p of (0, ..., n-1), for each i >= 1 we calulate how many + # entries in p[:i] are greater than p[i]. call this number c_i + # the coordinate is then calculated as 1! * c_1 + ... + (n-1)! * c_{n-1} + + # rather than calculate all the factorial numbers, we use a Horner-like algorithm + # 1! * c_1 + ... + (n-1)! * c_{n-1} = 1 * (c_1 + 2 * (c_2 + 3 * (c_3 + ...))) + out = 0 + n_items = len(items) + for j in range(n_items - 1, 0, -1): + out += sum(item > items[j] for item in items[:j]) + out *= j + return out + + +def _coord_to_perm[T](coord: int, items: tuple[T, ...]) -> tuple[T, ...]: + # given a coordinate and a number of items, recovers the permutation on n_items + # that would get assigned that coordinate by _perm_to_coord + + # we first extract the coefficients which tell us for each i >= 1, how many entries + # of p[:i] are greater than p[i], where p is the permutation we're trying to recover + # starting from i = n - 1 we can determine each entry of p based on the coefficients + # and by keeping track of which numbers have already been determined. + item_list = list(items) + n_items = len(items) + + coeffs = [0] * (n_items - 1) + for i in range(1, n_items): + coeffs[i - 1] = coord % (i + 1) + coord //= i + 1 + + perm: list[T] = [items[0]] * n_items + for i in range(n_items - 1, 0, -1): + perm[i] = item_list.pop(i - coeffs[i - 1]) + perm[0] = item_list[0] + + return tuple(perm) + + +def _slice_to_coord(edges: EDGE_PERM, slice_edges: set[Edge]) -> int: + # maps the position of slice_edges within edges onto 0, ..., 494 = 12C4 - 1 + + # given the locations (0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0) of four edges in a + # sequence of twelve, the coordinate is calculated by computing iC(s-1) at each 1, + # where i is the index of the 1 counting from the right, and s is 1 for the first 1, + # 2 for the second etc. + coord, seen = 0, 0 + for i, edge in enumerate(reversed(edges)): + if edge in slice_edges: + seen += 1 + coord += math.comb(i, seen) + return coord + + +def _coord_to_slice( + coord: int, slice_edges: EDGE_PERM, other_edges: EDGE_PERM, offset: int = 0 +) -> EDGE_PERM: + # combines slice_edges and other_edges into a tuple, with the positions of the + # slice_edges set in order that _slice_to_coord would recover coord. + + # to position the slice_edges, we subtract binomial coefficients from the coordinate + # for as long as the result is non-negative. if we encounter a coefficient that is + # larger than the remaining coordinate then we know we must have reached the next + # location of a slice_edge. + ep = [Edge.NULL] * 12 + # first we position the slice edges + seen = len(slice_edges) + for j in range(12): + if coord - math.comb(11 - j, seen) >= 0: + ep[j] = slice_edges[4 - seen] + coord -= math.comb(11 - j, seen) + seen -= 1 + + # then position the remaining edges in order + i = 0 + for j in range(12): + if ep[j] == Edge.NULL: + ep[j] = other_edges[i] + i += 1 + + return tuple(ep[offset:] + ep[:offset]) + + +def _parity(items: tuple[IntEnum, ...]) -> int: + # parity of a permutation is the number of swaps (mod 2) that need to be performed + # to sort the items. since every permutation of the cube results in an even number + # of swaps of pieces, the parity of the corner and edge permutations must be the + # same for the cube to be solvable + return sum(items[j] > items[i] for i in range(len(items)) for j in range(i)) % 2 + + +def _range_check(max_val: int): + # decorator to be used for input validation on coordinate setters + def decorator[T](fn: Callable[[T, int], None]) -> Callable[[T, int], None]: + name = getattr(fn, "__name__", repr(fn)) + + @functools.wraps(fn) + def wrapper(self: T, arg: int) -> None: + if not 0 <= arg < max_val: + raise ValueError( + f"{arg} is out of range for {name}. " + f"Choose a value in the range 0, ..., {max_val - 1}." + ) + fn(self, arg) + + return wrapper + + return decorator + + +@dataclass +class CubieCube: + cp: CORNER_PERM = SOLVED_CP + co: PIECE_ORI = SOLVED_CO + ep: EDGE_PERM = SOLVED_EP + eo: PIECE_ORI = SOLVED_EO + + @property + def twist(self) -> int: + """ + Compute twist, the coordinate representing corner orientation. We take the + orientation of the first 7 corners, represented as 0, 1 or 2 as there are three + possibilities, and view that as a ternary number in the range 0, ..., 3^7 - 1. + """ + twist = 0 + for co in self.co[:-1]: + twist = 3 * twist + co + return twist + + @twist.setter + @_range_check(TWIST_MAX) + def twist(self, twist: int) -> None: + """ + Set the twist of the cube. Each of the values 0, ..., 3^7-1 determines + a distinct way of orienting each of the 8 corners. + + Parameters + ---------- + twist : int + Orientation of the 8 corners encoded as twist coordinate. Must + satisfy 0 <= twist < 3^7. + """ + twist_parity = 0 + co = [0] * 8 + for i in range(7): + twist, co[6 - i] = divmod(twist, 3) + twist_parity += co[6 - i] + co[7] = (-twist_parity) % 3 + self.co = tuple(co) + + @property + def flip(self) -> int: + """ + Compute flip, the coordinate representing edge orientation. We take the + orientation of the first 11 edges, represented as 0 or 1 as there are two + possibilities, and view that as a binary number in the range 0, ..., 2^11 - 1. + """ + flip = 0 + for eo in self.eo[:-1]: + flip = 2 * flip + eo + return flip + + @flip.setter + @_range_check(FLIP_MAX) + def flip(self, flip: int) -> None: + """ + Set the flip of the cube. Each of the values 0, ..., 2^11-1 determines a + distinct way of orienting each of the 12 edges. + + Parameters + ---------- + flip : int + Orientation of the 12 corners encoded as flip coordinate. Must satisfy + 0 <= flip < 2^11. + """ + flip_parity = 0 + eo = [0] * 12 + for i in range(11): + flip, eo[10 - i] = divmod(flip, 2) + flip_parity += eo[10 - i] + eo[11] = (-flip_parity) % 2 + self.eo = tuple(eo) + + @property + def ud_slice(self) -> int: + """ + Compute ud_slice, the coordinate representing the location (but not + permutation) of the edges FR, FL, BL, BR. + """ + return _slice_to_coord(self.ep, set(UD_SLICE)) + + @ud_slice.setter + @_range_check(UD_SLICE_MAX) + def ud_slice(self, ud_slice: int) -> None: + """ + Set ud_slice. Each of the values 0, ..., 12C4 - 1 corresponds to a distinct way + of placing the four edges FR, FL, BL, BR in the 12 edge locations of the cube. + + Parameters + ---------- + ud_slice : int + Position of the aforementioned edges encoded as ud_slice coordinate. Must + satisfy 0 <= ud_slice < 12C4. + """ + self.ep = _coord_to_slice(ud_slice, tuple(UD_SLICE), tuple(U_EDGES + D_EDGES)) + + @property + def flip_ud_slice(self) -> int: + """ + Coordinate that combines flip and ud_slice into a single value. + """ + return FLIP_MAX * self.ud_slice + self.flip + + @flip_ud_slice.setter + @_range_check(FLIP_MAX * UD_SLICE_MAX) + def flip_ud_slice(self, flip_ud_slice: int) -> None: + """ + Jointly set ud_slice and flip coordinates. flip_ud_slice is assumed to take the + form ud_slice * 2^11 + flip. + + Parameters + ---------- + flip_ud_slice : int + A coordinate combining flip and ud_slice coordinates. Must satisfy + 0 <= flip_ud_slice < 1_013_760. + """ + ud_slice, flip = divmod(flip_ud_slice, FLIP_MAX) + self.ud_slice = ud_slice + self.flip = flip + + @property + def ud_slice_sorted(self) -> int: + """ + Compute ud_slice_sorted, the coordinate representing the location and + permutation of the edges FR, FL, BL, BR. + """ + return EDGE4_MAX * _slice_to_coord(self.ep, set(UD_SLICE)) + _perm_to_coord( + tuple(edge for edge in self.ep if edge in set(UD_SLICE)) + ) + + @ud_slice_sorted.setter + @_range_check(math.comb(12, 4) * EDGE4_MAX) + def ud_slice_sorted(self, ud_slice_sorted: int) -> None: + """ + Set ud_slice_sorted. ud_slice_sorted // 24 is the ud_slice coordinate + corresponding to the location of the edges FR, FL, BL, BR. ud_slice_sorted % 24 + is the relative permutation of those edges. + + Parameters + ---------- + ud_slice_sorted : int + Position and permutation of the aforementioned edges encoded as + ud_slice_sorted coordinate. Must satisfy 0 <= ud_slice_sorted < 12C4 * 4! + """ + ud_slice, edge4 = divmod(ud_slice_sorted, EDGE4_MAX) + slice_edges = _coord_to_perm(edge4, tuple(UD_SLICE)) + self.ep = _coord_to_slice(ud_slice, slice_edges, tuple(U_EDGES + D_EDGES)) + + @property + def u_edges_sorted(self) -> int: + """ + Compute ud_slice_sorted, the coordinate representing the location and + permutation of the edges UR, UF, UL, UB. + """ + # we reverse the edges for calculating the relative position, this means that + # the u_edges_sorted coordinate will take values in 0, ..., 1679 in phase 2 + ep = tuple(self.ep) + return EDGE4_MAX * _slice_to_coord(ep[::-1], set(U_EDGES)) + _perm_to_coord( + tuple(edge for edge in ep if edge in set(U_EDGES)) + ) + + @u_edges_sorted.setter + @_range_check(math.comb(12, 4) * EDGE4_MAX) + def u_edges_sorted(self, u_edges_sorted: int) -> None: + """ + Set u_edges_sorted. u_edges_sorted // 24 is the ud_slice coordinate + corresponding to the location of the edges UR, UF, UL, UB. u_edges_sorted % 24 + is the relative permutation of those edges. + + Parameters + ---------- + u_edges_sorted : int + Position and permutation of the aforementioned edges encoded as + u_edges_sorted coordinate. Must satisfy 0 <= u_edges_sorted < 12C4 * 4! + """ + u_edges, perm_coord = divmod(u_edges_sorted, EDGE4_MAX) + perm = _coord_to_perm(perm_coord, tuple(U_EDGES)) + # reverse the u edges before placing them, so that when we reverse the full edge + # permutation we end up with things the right way again. this messiness is to + # make the transition to phase 2 easier by ensuring that the u edges do not + # occupy the ud-slice for coordinates 0, ..., 1679 + self.ep = _coord_to_slice( + u_edges, tuple(reversed(perm)), tuple(reversed(D_EDGES + UD_SLICE)) + )[::-1] + + @property + def d_edges_sorted(self) -> int: + """ + Compute ud_slice_sorted, the coordinate representing the location and + permutation of the edges DR, DF, DL, DB. + """ + ep = self.ep[8:] + self.ep[:8] + return EDGE4_MAX * _slice_to_coord(ep, set(D_EDGES)) + _perm_to_coord( + tuple(edge for edge in ep if edge in set(D_EDGES)) + ) + + @d_edges_sorted.setter + @_range_check(math.comb(12, 4) * EDGE4_MAX) + def d_edges_sorted(self, d_edges_sorted: int) -> None: + """ + Set d_edges_sorted. d_edges_sorted // 24 is the ud_slice coordinate + corresponding to the location of the edges DR, DF, DL, DB. d_edges_sorted % 24 + is the relative permutation of those edges. + + Parameters + ---------- + d_edges_sorted : int + Position and permutation of the aforementioned edges encoded as + d_edges_sorted coordinate. Must satisfy 0 <= d_edges_sorted < 12C4 * 4! + """ + d_edges, perm_coord = divmod(d_edges_sorted, EDGE4_MAX) + perm = _coord_to_perm(perm_coord, tuple(D_EDGES)) + self.ep = _coord_to_slice(d_edges, perm, tuple(UD_SLICE + U_EDGES), offset=4) + + @property + def corner(self) -> int: + """ + Compute corner, the coordinate representing permutation of the 8 + corners. + + There are 8 possible positions for the 8 corners, so corner takes + values in the range 0, ..., 8! - 1. + """ + return _perm_to_coord(self.cp) + + @corner.setter + @_range_check(CORNER_MAX) + def corner(self, corner: int) -> None: + """ + Set the corner of the cube. Each of the values 0, ..., 8! - 1 + determines a distinct permutation of the 8 corners. + + Parameters + ---------- + corner : int + Order of the 8 corners encoded as corner coordinate. Must satisfy + 0 <= corner < 8! + """ + self.cp = _coord_to_perm(corner, SOLVED_CP) + + @property + def edge8(self) -> int: + """ + Compute edge8, the coordinate representing permutation of the 8 edges + UR, UF, UL, UB, DR, DF, DL, DB. In phase 2 these edges will all be in + the U and D slices. + + There are 8 possible positions for the 8 edges, so edge8 takes values + in the range 0, ..., 8! - 1. + """ + return _perm_to_coord(self.ep[:8]) + + @edge8.setter + @_range_check(EDGE8_MAX) + def edge8(self, edge8: int) -> None: + """ + Set the edge8 of the cube. Each of the values 0, ..., 8! - 1 determines + a distinct order of the 8 edges UR, UF, UL, UB, DR, DF, DL, DB in the U + and D slices during phase 2. + + Parameters + ---------- + edge8 : int + Order of the 8 aforementioned edges encoded as edge8 coordinate. + Must satisfy 0 <= edge8 < 8! + """ + self.ep = _coord_to_perm(edge8, SOLVED_EP[:8]) + self.ep[8:] + + @property + def corner_parity(self) -> int: + """ + Corner parity of the CubieCube. The number of corner swaps that must be + performed to reach the solved state. A cube is solvable if and only if the + corner parity matches the edge parity. + """ + return _parity(self.cp) + + @property + def edge_parity(self) -> int: + """ + Edge parity of the CubieCube. The number of edge swaps that must be performed + to reach the solved state. A cube is solvable if and only if the edge parity + matches the corner parity. + """ + return _parity(self.ep) + + @staticmethod + def _corner_orientation(ori_self, ori_move): + # if self is mirrored, subtract orientations of move, otherwise update as usual + ori = ori_self + ori_move if ori_self < 3 else ori_self - ori_move + if (ori_self < 3) ^ (ori_move < 3): + # result is mirrored cube + return (ori % 3) + 3 + return ori % 3 + + def corner_multiply(self, move: CubieCube) -> CubieCube: + return CubieCube( + cp=tuple(self.cp[move.cp[i]] for i in range(8)), + co=tuple( + self._corner_orientation(self.co[move.cp[i]], move.co[i]) + for i in range(8) + ), + ep=self.ep, + eo=self.eo, + ) + + def edge_multiply(self, move: CubieCube) -> CubieCube: + return CubieCube( + cp=self.cp, + co=self.co, + ep=tuple(self.ep[move.ep[i]] for i in range(12)), + eo=tuple((self.eo[move.ep[i]] + move.eo[i]) % 2 for i in range(12)), + ) + + def multiply(self, move: CubieCube) -> CubieCube: + # while we could chain corner_multiple and edge_multiply, it's slightly more + # efficient to do it all in one go even though it means some code duplication + return CubieCube( + cp=tuple(self.cp[move.cp[i]] for i in range(8)), + co=tuple( + self._corner_orientation(self.co[move.cp[i]], move.co[i]) + for i in range(8) + ), + ep=tuple(self.ep[move.ep[i]] for i in range(12)), + eo=tuple((self.eo[move.ep[i]] + move.eo[i]) % 2 for i in range(12)), + ) + + def __mul__(self, move: CubieCube) -> CubieCube: + return self.multiply(move) + + def invert(self) -> CubieCube: + """ + Calculate the inverse of the given CubieCube. + """ + ep = [Edge.NULL] * 12 + eo = [-1] * 12 + for edge in SOLVED_EP: + ep[self.ep[edge]] = edge + for edge in SOLVED_EP: + eo[edge] = self.eo[ep[edge]] + + cp = [Corner.NULL] * 8 + co = [-1] * 8 + for corner in SOLVED_CP: + cp[self.cp[corner]] = corner + for corner in SOLVED_CP: + ori = self.co[cp[corner]] + co[corner] = ori if ori >= 3 else (-ori % 3) + + return CubieCube(cp=tuple(cp), co=tuple(co), ep=tuple(ep), eo=tuple(eo)) + + +_MOVE_U = CubieCube( + cp=( + Corner.UBR, + Corner.URF, + Corner.UFL, + Corner.ULB, + Corner.DFR, + Corner.DLF, + Corner.DBL, + Corner.DRB, + ), + co=(0,) * 8, + ep=( + Edge.UB, + Edge.UR, + Edge.UF, + Edge.UL, + Edge.DR, + Edge.DF, + Edge.DL, + Edge.DB, + Edge.FR, + Edge.FL, + Edge.BL, + Edge.BR, + ), + eo=(0,) * 12, +) +_MOVE_R = CubieCube( + cp=( + Corner.DFR, + Corner.UFL, + Corner.ULB, + Corner.URF, + Corner.DRB, + Corner.DLF, + Corner.DBL, + Corner.UBR, + ), + co=(2, 0, 0, 1, 1, 0, 0, 2), + ep=( + Edge.FR, + Edge.UF, + Edge.UL, + Edge.UB, + Edge.BR, + Edge.DF, + Edge.DL, + Edge.DB, + Edge.DR, + Edge.FL, + Edge.BL, + Edge.UR, + ), + eo=(0,) * 12, +) +_MOVE_F = CubieCube( + cp=( + Corner.UFL, + Corner.DLF, + Corner.ULB, + Corner.UBR, + Corner.URF, + Corner.DFR, + Corner.DBL, + Corner.DRB, + ), + co=(1, 2, 0, 0, 2, 1, 0, 0), + ep=( + Edge.UR, + Edge.FL, + Edge.UL, + Edge.UB, + Edge.DR, + Edge.FR, + Edge.DL, + Edge.DB, + Edge.UF, + Edge.DF, + Edge.BL, + Edge.BR, + ), + eo=(0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0), +) +_MOVE_D = CubieCube( + cp=( + Corner.URF, + Corner.UFL, + Corner.ULB, + Corner.UBR, + Corner.DLF, + Corner.DBL, + Corner.DRB, + Corner.DFR, + ), + co=(0,) * 8, + ep=( + Edge.UR, + Edge.UF, + Edge.UL, + Edge.UB, + Edge.DF, + Edge.DL, + Edge.DB, + Edge.DR, + Edge.FR, + Edge.FL, + Edge.BL, + Edge.BR, + ), + eo=(0,) * 12, +) +_MOVE_L = CubieCube( + cp=( + Corner.URF, + Corner.ULB, + Corner.DBL, + Corner.UBR, + Corner.DFR, + Corner.UFL, + Corner.DLF, + Corner.DRB, + ), + co=(0, 1, 2, 0, 0, 2, 1, 0), + ep=( + Edge.UR, + Edge.UF, + Edge.BL, + Edge.UB, + Edge.DR, + Edge.DF, + Edge.FL, + Edge.DB, + Edge.FR, + Edge.UL, + Edge.DL, + Edge.BR, + ), + eo=(0,) * 12, +) +_MOVE_B = CubieCube( + cp=( + Corner.URF, + Corner.UFL, + Corner.UBR, + Corner.DRB, + Corner.DFR, + Corner.DLF, + Corner.ULB, + Corner.DBL, + ), + co=(0, 0, 1, 2, 0, 0, 2, 1), + ep=( + Edge.UR, + Edge.UF, + Edge.UL, + Edge.BR, + Edge.DR, + Edge.DF, + Edge.DL, + Edge.BL, + Edge.FR, + Edge.FL, + Edge.UB, + Edge.DB, + ), + eo=(0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1), +) + +_MOVES = [_MOVE_U, _MOVE_R, _MOVE_F, _MOVE_D, _MOVE_L, _MOVE_B] + + +class Moves: + _moves: tuple[CubieCube, ...] = () + + def __new__(cls) -> Moves: + if not cls._moves: + moves: list[CubieCube] = [] + for mv in _MOVES: + cube = CubieCube() + for _ in range(3): + cube *= mv + moves.append(cube) + cls._moves = tuple(moves) + + return super().__new__(cls) + + def __getitem__(self, idx: int | slice) -> CubieCube | tuple[CubieCube, ...]: + return self._moves[idx] + + def __len__(self) -> int: + return len(self._moves) + + def __iter__(self) -> Iterator[CubieCube]: + return iter(self._moves) + + +def move(cube: CubieCube, i: int, corner: bool = True, edge: bool = True) -> CubieCube: + if corner: + if edge: + return cube.multiply(_MOVES[i]) + return cube.corner_multiply(_MOVES[i]) + elif edge: + return cube.edge_multiply(_MOVES[i]) + raise ValueError("At least one of corner and edge must be set to True") + + +def verify(cube: CubieCube) -> None: + # check all pieces appear exactly once + edges = set() + for edge in cube.ep: + if edge in edges: + raise InvalidCube(f"{edge!r} appears more than once") + edges.add(edge) + + corners = set() + for corner in cube.cp: + if corner in corners: + raise InvalidCube(f"{corner!r} appears more than once") + corners.add(corner) + + # check orientation of pieces + if sum(cube.eo) % 2 != 0: + raise InvalidCube("Flip error: an edge must be flipped") + if sum(cube.co) % 3 != 0: + raise InvalidCube("Twist error: a corner must be twisted") + + # check parity / compatibility of edges and corners + if cube.edge_parity != cube.corner_parity: + raise InvalidCube("Parity error: two edges or two corners must be swapped.") diff --git a/twophase/__init__.py b/twophase/__init__.py deleted file mode 100644 index ecf67af..0000000 --- a/twophase/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -import time - -from .solve import SolutionManager - - -def solve(cube_string, max_length=25, max_time=10): - """ - Solve the cube specified by cube_string, return the first solution found - as long as max_time not exceeded. - """ - sm = SolutionManager(cube_string) - solution = sm.solve(max_length, time.time() + max_time) - if isinstance(solution, str): - return solution - elif solution == -2: - raise RuntimeError("max_time exceeded, no solution found") - elif solution == -1: - raise RuntimeError("no solution found, try increasing max_length") - raise RuntimeError( - f"SolutionManager.solve: unexpected return value {solution}" - ) - - -def solve_best(cube_string, max_length=25, max_time=10): - """ - Solve the cube repeatedly, reducing max_length each time a solution is - found until timeout is reached or no more solutions are found. - - Returns all solutions found as a list. - """ - return list(solve_best_generator(cube_string, max_length, max_time)) - - -def solve_best_generator(cube_string, max_length=25, max_time=10): - """ - Solve the cube repeatedly, reducing max_length each time a solution is - found until timeout is reached or no more solutions are found. - - Yields the solution each time it is found. - """ - sm = SolutionManager(cube_string) - timeout = time.time() + max_time - while True: - solution = sm.solve(max_length, timeout) - - if isinstance(solution, str): - yield solution - max_length = len(solution.split(" ")) - 1 - elif solution == -2 or solution == -1: - # timeout or no more solutions - break - else: - raise RuntimeError( - f"SolutionManager.solve: unexpected return value {solution}" - ) diff --git a/twophase/cubes/__init__.py b/twophase/cubes/__init__.py deleted file mode 100644 index 1f53f8a..0000000 --- a/twophase/cubes/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .coordcube import CoordCube -from .cubiecube import CubieCube -from .facecube import FaceCube - -__all__ = ["CoordCube", "CubieCube", "FaceCube"] diff --git a/twophase/cubes/coordcube.py b/twophase/cubes/coordcube.py deleted file mode 100644 index fcdccf2..0000000 --- a/twophase/cubes/coordcube.py +++ /dev/null @@ -1,55 +0,0 @@ -from ..tables import Tables -from .cubiecube import CubieCube - - -class CoordCube: - """ - Coordinate representation of cube. Updates coordinates using pre-computed - move tables. - """ - - def __init__(self, twist=0, flip=0, udslice=0, edge4=0, edge8=0, corner=0): - self.tables = Tables() - - # initialise from cubiecube c - self.twist = twist - self.flip = flip - self.udslice = udslice - self.edge4 = edge4 - self.edge8 = edge8 - self.corner = corner - - @classmethod - def from_cubiecube(cls, cube): - """ - Create a CoordCube from an existing CubieCube. - """ - if not isinstance(cube, CubieCube): - raise TypeError("Expected argument of type CubieCube") - return cls( - cube.twist, - cube.flip, - cube.udslice, - cube.edge4, - cube.edge8, - cube.corner, - ) - - def move(self, mv): - """ - Update all coordinates after applying move mv using the move tables. - - Parameters - ---------- - mv : int - Integer representing one of 18 non-identity face turns. Calulate as - 3 * i + j where i = 0, 1, 2, 3, 4, 5 for U, R, F, D, L, B - respectively, and j = 0, 1, 2 for quarter turn clockwise, half turn - and quarter turn anticlockwise respectively. - """ - self.twist = self.tables.twist_move[self.twist][mv] - self.flip = self.tables.flip_move[self.flip][mv] - self.udslice = self.tables.udslice_move[self.udslice][mv] - self.edge4 = self.tables.edge4_move[self.edge4][mv] - self.edge8 = self.tables.edge8_move[self.edge8][mv] - self.corner = self.tables.corner_move[self.corner][mv] diff --git a/twophase/cubes/cubiecube.py b/twophase/cubes/cubiecube.py deleted file mode 100644 index 49b03cb..0000000 --- a/twophase/cubes/cubiecube.py +++ /dev/null @@ -1,767 +0,0 @@ -""" -This class describes cubes on the level of the cubies. -""" -from functools import reduce - -from ..pieces import Corner, Edge -from . import facecube - - -def choose(n, k): - """ - A fast way to compute binomial coefficients by Andrew Dalke. - """ - if 0 <= k <= n: - num = 1 - den = 1 - for i in range(1, min(k, n - k) + 1): - num *= n - den *= i - n -= 1 - return num // den - else: - return 0 - - -# Moves on the cubie level, gives permutation and orientation after the moves -# U, R, F, D, L, B resp from a clean cube. This will be used to compute move -# tables and the composition rules. -_cpU = ( - Corner.UBR, - Corner.URF, - Corner.UFL, - Corner.ULB, - Corner.DFR, - Corner.DLF, - Corner.DBL, - Corner.DRB, -) -_coU = (0, 0, 0, 0, 0, 0, 0, 0) -_epU = ( - Edge.UB, - Edge.UR, - Edge.UF, - Edge.UL, - Edge.DR, - Edge.DF, - Edge.DL, - Edge.DB, - Edge.FR, - Edge.FL, - Edge.BL, - Edge.BR, -) -_eoU = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) - -_cpR = ( - Corner.DFR, - Corner.UFL, - Corner.ULB, - Corner.URF, - Corner.DRB, - Corner.DLF, - Corner.DBL, - Corner.UBR, -) -_coR = (2, 0, 0, 1, 1, 0, 0, 2) -_epR = ( - Edge.FR, - Edge.UF, - Edge.UL, - Edge.UB, - Edge.BR, - Edge.DF, - Edge.DL, - Edge.DB, - Edge.DR, - Edge.FL, - Edge.BL, - Edge.UR, -) -_eoR = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) - -_cpF = ( - Corner.UFL, - Corner.DLF, - Corner.ULB, - Corner.UBR, - Corner.URF, - Corner.DFR, - Corner.DBL, - Corner.DRB, -) -_coF = (1, 2, 0, 0, 2, 1, 0, 0) -_epF = ( - Edge.UR, - Edge.FL, - Edge.UL, - Edge.UB, - Edge.DR, - Edge.FR, - Edge.DL, - Edge.DB, - Edge.UF, - Edge.DF, - Edge.BL, - Edge.BR, -) -_eoF = (0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0) - -_cpD = ( - Corner.URF, - Corner.UFL, - Corner.ULB, - Corner.UBR, - Corner.DLF, - Corner.DBL, - Corner.DRB, - Corner.DFR, -) -_coD = (0, 0, 0, 0, 0, 0, 0, 0) -_epD = ( - Edge.UR, - Edge.UF, - Edge.UL, - Edge.UB, - Edge.DF, - Edge.DL, - Edge.DB, - Edge.DR, - Edge.FR, - Edge.FL, - Edge.BL, - Edge.BR, -) -_eoD = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) - -_cpL = ( - Corner.URF, - Corner.ULB, - Corner.DBL, - Corner.UBR, - Corner.DFR, - Corner.UFL, - Corner.DLF, - Corner.DRB, -) -_coL = (0, 1, 2, 0, 0, 2, 1, 0) -_epL = ( - Edge.UR, - Edge.UF, - Edge.BL, - Edge.UB, - Edge.DR, - Edge.DF, - Edge.FL, - Edge.DB, - Edge.FR, - Edge.UL, - Edge.DL, - Edge.BR, -) -_eoL = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) - -_cpB = ( - Corner.URF, - Corner.UFL, - Corner.UBR, - Corner.DRB, - Corner.DFR, - Corner.DLF, - Corner.ULB, - Corner.DBL, -) -_coB = (0, 0, 1, 2, 0, 0, 2, 1) -_epB = ( - Edge.UR, - Edge.UF, - Edge.UL, - Edge.BR, - Edge.DR, - Edge.DF, - Edge.DL, - Edge.BL, - Edge.FR, - Edge.FL, - Edge.UB, - Edge.DB, -) -_eoB = (0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1) - - -class CubieCube: - def __init__(self, cp=None, co=None, ep=None, eo=None): - if cp and co and ep and eo: - self.cp = cp[:] - self.co = co[:] - self.ep = ep[:] - self.eo = eo[:] - else: - # Initialise clean cube if position not given. - self.cp = [ - Corner.URF, - Corner.UFL, - Corner.ULB, - Corner.UBR, - Corner.DFR, - Corner.DLF, - Corner.DBL, - Corner.DRB, - ] - self.co = [0, 0, 0, 0, 0, 0, 0, 0] - self.ep = [ - Edge.UR, - Edge.UF, - Edge.UL, - Edge.UB, - Edge.DR, - Edge.DF, - Edge.DL, - Edge.DB, - Edge.FR, - Edge.FL, - Edge.BL, - Edge.BR, - ] - self.eo = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] - - def corner_multiply(self, b): - """ - Compute permutation and orientation of corners after applying - permutation represented by b to current cube. - - Parameters - ---------- - b : CubieCube - Permutation to apply represented as a CubieCube. - - Notes - ----- - We use "is replaced by" notation for the permutations. So b.cp[URF] - gives the index of the piece that replaces URF when applying b. To - explain the rules we use the example of first applying F then applying - R. - - Under F we have URF<-UFL and under R we have UBR<-URF. Hence under FR - we have UBR<-UFL, leading to the rule - - (F*R).cp[UBR] = F.cp[R.cp[UBR]] - - The corner orientation arrays tell us the change in orientation of a - piece (based on where it ends up) due to that move. So for example - F.co[URF] = 1, which means that as UFL moves to URF under the F move, - its orientation increases by 1. To get the total change in orientation - of the piece that moves to UBR under FR we use the rule - - (F*R).co[UBR] = F.co[R.cp[UBR]] + R.co[UBR]. - """ - corner_perm = [self.cp[b.cp[i]] for i in range(8)] - corner_ori = [(self.co[b.cp[i]] + b.co[i]) % 3 for i in range(8)] - self.co = corner_ori[:] - self.cp = corner_perm[:] - - def edge_multiply(self, b): - """ - Compute permutation and orientation of edges after applying permutation - represented by b to current cube. - - Parameters - ---------- - b : CubieCube - Permutation to apply represented as a CubieCube. - - Notes - ----- - See docstring of corner_multiply (which operates analogously to this - method) for a description of how the update rules are derived. - """ - edge_perm = [self.ep[b.ep[i]] for i in range(12)] - edge_ori = [(self.eo[b.ep[i]] + b.eo[i]) % 2 for i in range(12)] - self.eo = edge_ori[:] - self.ep = edge_perm[:] - - def multiply(self, b): - """ - Compute permutation and orientation of edges and corners after applying - permutation represented by b to the current cube. - - Parameters - ---------- - b : CubieCube - Permutation to apply represented as a CubieCube - """ - self.corner_multiply(b) - self.edge_multiply(b) - - def move(self, i): - """ - Helper function for applying one of 6 canonical moves - """ - self.multiply(MOVE_CUBE[i]) - - def inverse_cubiecube(self): - """ - Compute the inverse of the current cube. - """ - cube = CubieCube() - for e in range(12): - cube.ep[self.ep[e]] = e - for e in range(12): - cube.eo[e] = self.eo[cube.ep[e]] - for c in range(8): - cube.cp[self.cp[c]] = c - for c in range(8): - ori = self.co[cube.cp[c]] - cube.co[c] = (-ori) % 3 - return cube - - def to_facecube(self): - """ - Convert CubieCube to FaceCube. - """ - ret = facecube.FaceCube() - for i in range(8): - j = self.cp[i] - ori = self.co[i] - for k in range(3): - ret.f[ - facecube.corner_facelet[i][(k + ori) % 3] - ] = facecube.corner_color[j][k] - for i in range(12): - j = self.ep[i] - ori = self.eo[i] - for k in range(2): - facelet_index = facecube.edge_facelet[i][(k + ori) % 2] - ret.f[facelet_index] = facecube.edge_color[j][k] - return ret - - @property - def corner_parity(self): - """ - Corner parity of the CubieCube. Cube is solveable if and only if this - matches the edge parity. - """ - s = 0 - for i in range(7, 0, -1): - for j in range(i - 1, -1, -1): - if self.cp[j] > self.cp[i]: - s += 1 - return s % 2 - - @property - def edge_parity(self): - """ - Edge parity of the CubieCube. Cube is solveable if and only if this - matches the corner parity. - """ - s = 0 - for i in range(11, 0, -1): - for j in range(i - 1, -1, -1): - if self.ep[j] > self.ep[i]: - s += 1 - return s % 2 - - # ---------- Phase 1 Coordinates ---------- # - @property - def twist(self): - """ - Compute twist, the coordinate representing corner orientation. We take - the orientation of the first 7 corners, represented as 0, 1 or 2 as - there are three possibilities, and view that as a ternary number in the - range 0, ..., 3^7 - 1. - - Notes - ----- - The orientation of the first eleven corners determines the orientation - of the last, hence we only include the orientation of the first 7 - corners in the calculation of twist. - """ - return reduce(lambda x, y: 3 * x + y, self.co[:7]) - - @twist.setter - def twist(self, twist): - """ - Set the twist of the cube. Each of the values 0, ..., 3^7-1 determines - a distinct way of orienting each of the 8 corners. - - Parameters - ---------- - twist : int - Orientation of the 8 corners encoded as twist coordinate. Must - satisfy 0 <= twist < 3^7. - """ - if not 0 <= twist < 3 ** 7: - raise ValueError( - "{} is out of range for twist, must take values in " - "0, ..., 2186.".format(twist) - ) - total = 0 - for i in range(7): - x = twist % 3 - self.co[6 - i] = x - total += x - twist //= 3 - self.co[7] = (-total) % 3 - - @property - def flip(self): - """ - Compute flip, the coordinate representing edge orientation. We take - the orientation of the first 11 edges, represented as 0 or 1 as there - are two possibilities, and view that as a binary number in the range - 0, ..., 2^11 - 1. - - Notes - ----- - The orientation of the first eleven edges determines the orientation - of the last, hence we only include the orientation of the first 11 - edges in the calculation of flip. - """ - return reduce(lambda x, y: 2 * x + y, self.eo[:11]) - - @flip.setter - def flip(self, flip): - """ - Set the flip of the cube. Each of the values 0, ..., 2^11-1 determines - a distinct way of orienting each of the 12 edges. - - Parameters - ---------- - flip : int - Orientation of the 12 corners encoded as flip coordinate. Must - satisfy 0 <= flip < 2^11. - """ - if not 0 <= flip < 2 ** 11: - raise ValueError( - "{} is out of range for flip, must take values in " - "0, ..., 2047.".format(flip) - ) - total = 0 - for i in range(11): - x = flip % 2 - self.eo[10 - i] = x - total += x - flip //= 2 - self.eo[11] = (-total) % 2 - - @property - def udslice(self): - """ - Compute udslice, the coordinate representing position, but not the - order, of the 4 edges FR, FL, BL, BR. These 4 edges must be in the - middle layer for phase 2 to begin. If they are in the middle layer, - udslice will have the value 0. - - Since there are 12 possible positions and we care only about those 4 - edges, udslice takes values in the range 0, ..., 12C4 - 1. - """ - udslice, seen = 0, 0 - for j in range(12): - if 8 <= self.ep[j] < 12: - seen += 1 - elif seen >= 1: - udslice += choose(j, seen - 1) - return udslice - - @udslice.setter - def udslice(self, udslice): - """ - Set the udslice of the cube. Each of the values 0, ..., 12C4 - 1 - determines a distinct set of 4 positions for the edges FR, FL, BL, BR - to occupy. Note that it does not determine the order of these edges. - - Parameters - ---------- - udslice : int - Position of the 4 aforementioned edges encoded as udslice - coordinate. Must satisfy 0 <= slice < 12C4. - """ - if not 0 <= udslice < choose(12, 4): - raise ValueError( - "{} is out of range for udslice, must take values in " - "0, ..., 494.".format(udslice) - ) - udslice_edge = [Edge.FR, Edge.FL, Edge.BL, Edge.BR] - other_edge = [ - Edge.UR, - Edge.UF, - Edge.UL, - Edge.UB, - Edge.DR, - Edge.DF, - Edge.DL, - Edge.DB, - ] - # invalidate edges - for i in range(12): - self.ep[i] = Edge.DB - # we first position the slice edges - seen = 3 - for j in range(11, -1, -1): - if udslice - choose(j, seen) < 0: - self.ep[j] = udslice_edge[seen] - seen -= 1 - else: - udslice -= choose(j, seen) - # then the remaining edges - x = 0 - for j in range(12): - if self.ep[j] == Edge.DB: - self.ep[j] = other_edge[x] - x += 1 - - # ---------- Phase 2 Coordinates ---------- # - @property - def edge4(self): - """ - Compute edge4, the coordinate representing permutation of the 4 edges - FR, FL, BL, FR. This assumes that the cube is in phase 2 position, so - in particular the 4 edges are correctly placed, just perhaps not - correctly ordered. edge4 takes values in the range 0, ..., 4! - 1 = 23. - """ - edge4 = self.ep[8:] - ret = 0 - for j in range(3, 0, -1): - s = 0 - for i in range(j): - if edge4[i] > edge4[j]: - s += 1 - ret = j * (ret + s) - return ret - - @edge4.setter - def edge4(self, edge4): - """ - Set the edge4 of the cube. Each of the values 0, ..., 4! - 1 determines - a distinct order of the 4 edges FR, FL, BL, BR in the middle slice - during phase 2. - - Parameters - ---------- - edge4 : int - Order of the 4 aforementioned edges encoded as edge4 coordinate. - Must satisfy 0 <= edge4 < 4! - """ - if not 0 <= edge4 < 24: - raise ValueError( - f"{edge4} is out of range for edge4, must take values in 0-23" - ) - sliceedge = [Edge.FR, Edge.FL, Edge.BL, Edge.BR] - coeffs = [0] * 3 - for i in range(1, 4): - coeffs[i - 1] = edge4 % (i + 1) - edge4 //= i + 1 - perm = [0] * 4 - for i in range(2, -1, -1): - perm[i + 1] = sliceedge.pop(i + 1 - coeffs[i]) - perm[0] = sliceedge[0] - self.ep[8:] = perm[:] - - @property - def edge8(self): - """ - Compute edge8, the coordinate representing permutation of the 8 edges - UR, UF, UL, UB, DR, DF, DL, DB. In phase 2 these edges will all be in - the U and D slices. - - There are 8 possible positions for the 8 edges, so edge8 takes values - in the range 0, ..., 8! - 1. - """ - edge8 = 0 - for j in range(7, 0, -1): - s = 0 - for i in range(j): - if self.ep[i] > self.ep[j]: - s += 1 - edge8 = j * (edge8 + s) - return edge8 - - @edge8.setter - def edge8(self, edge8): - """ - Set the edge8 of the cube. Each of the values 0, ..., 8! - 1 determines - a distinct order of the 8 edges UR, UF, UL, UB, DR, DF, DL, DB in the U - and D slices during phase 2. - - Parameters - ---------- - edge8 : int - Order of the 8 aforementioned edges encoded as edge8 coordinate. - Must satisfy 0 <= edge8 < 8! - """ - edges = list(range(8)) - perm = [0] * 8 - coeffs = [0] * 7 - for i in range(1, 8): - coeffs[i - 1] = edge8 % (i + 1) - edge8 //= i + 1 - for i in range(6, -1, -1): - perm[i + 1] = edges.pop(i + 1 - coeffs[i]) - perm[0] = edges[0] - self.ep[:8] = perm[:] - - @property - def corner(self): - """ - Compute corner, the coordinate representing permutation of the 8 - corners. - - There are 8 possible positions for the 8 corners, so corner takes - values in the range 0, ..., 8! - 1. - """ - c = 0 - for j in range(7, 0, -1): - s = 0 - for i in range(j): - if self.cp[i] > self.cp[j]: - s += 1 - c = j * (c + s) - return c - - @corner.setter - def corner(self, corn): - """ - Set the corner of the cube. Each of the values 0, ..., 8! - 1 - determines a distinct permutation of the 8 corners. - - Parameters - ---------- - corner : int - Order of the 8 corners encoded as corner coordinate. Must satisfy - 0 <= corner < 8! - """ - corners = list(range(8)) - perm = [0] * 8 - coeffs = [0] * 7 - for i in range(1, 8): - coeffs[i - 1] = corn % (i + 1) - corn //= i + 1 - for i in range(6, -1, -1): - perm[i + 1] = corners.pop(i + 1 - coeffs[i]) - perm[0] = corners[0] - self.cp = perm[:] - - # ---------- Misc. Coordinates ---------- # - - # edge permutation coordinate not used in solving, - # but needed to generate random cubes - @property - def edge(self): - """ - Compute edge, the coordinate representing permutation of the 12 - corners. - - There are 12 possible positions for the 12 edges, so edge takes values - in the range 0, ..., 12! - 1. - """ - e = 0 - for j in range(11, 0, -1): - s = 0 - for i in range(j): - if self.ep[i] > self.ep[j]: - s += 1 - e = j * (e + s) - return e - - @edge.setter - def edge(self, edge): - """ - Set the edge8 of the cube. Each of the values 0, ..., 8! - 1 determines - a distinct order of the 8 edges UR, UF, UL, UB, DR, DF, DL, DB in the U - and D slices during phase 2. - - Parameters - ---------- - edge8 : int - Order of the 8 aforementioned edges encoded as edge8 coordinate. - Must satisfy 0 <= edge8 < 8! - """ - edges = list(range(12)) - perm = [0] * 12 - coeffs = [0] * 11 - for i in range(1, 12): - coeffs[i - 1] = edge % (i + 1) - edge //= i + 1 - for i in range(10, -1, -1): - perm[i + 1] = edges.pop(i + 1 - coeffs[i]) - perm[0] = edges[0] - self.ep = perm[:] - - # ---------- Solvability Check ---------- # - - # Check a cubiecube for solvability - # - def verify(self): - """ - Check if current cube position is solvable. - - Returns - ------- - int: - Integer encoding solvability of cube. - 0: Solvable - -2: not all 12 edges exist exactly once - -3: flip error: one edge should be flipped - -4: not all corners exist exactly once - -5: twist error - a corner must be twisted - -6: Parity error - two corners or edges have to be exchanged - """ - total = 0 - edge_count = [0 for i in range(12)] - for e in range(12): - edge_count[self.ep[e]] += 1 - for i in range(12): - if edge_count[i] != 1: - return -2 - for i in range(12): - total += self.eo[i] - if total % 2 != 0: - return -3 - corner_count = [0] * 8 - for c in range(8): - corner_count[self.cp[c]] += 1 - for i in range(8): - if corner_count[i] != 1: - return -4 - total = 0 - for i in range(8): - total += self.co[i] - if total % 3 != 0: - return -5 - if self.edge_parity != self.corner_parity: - return -6 - return 0 - - -# we store the six possible clockwise 1/4 turn moves in the following array. -MOVE_CUBE = [CubieCube() for i in range(6)] - -MOVE_CUBE[0].cp = _cpU -MOVE_CUBE[0].co = _coU -MOVE_CUBE[0].ep = _epU -MOVE_CUBE[0].eo = _eoU - -MOVE_CUBE[1].cp = _cpR -MOVE_CUBE[1].co = _coR -MOVE_CUBE[1].ep = _epR -MOVE_CUBE[1].eo = _eoR - -MOVE_CUBE[2].cp = _cpF -MOVE_CUBE[2].co = _coF -MOVE_CUBE[2].ep = _epF -MOVE_CUBE[2].eo = _eoF - -MOVE_CUBE[3].cp = _cpD -MOVE_CUBE[3].co = _coD -MOVE_CUBE[3].ep = _epD -MOVE_CUBE[3].eo = _eoD - -MOVE_CUBE[4].cp = _cpL -MOVE_CUBE[4].co = _coL -MOVE_CUBE[4].ep = _epL -MOVE_CUBE[4].eo = _eoL - -MOVE_CUBE[5].cp = _cpB -MOVE_CUBE[5].co = _coB -MOVE_CUBE[5].ep = _epB -MOVE_CUBE[5].eo = _eoB diff --git a/twophase/cubes/facecube.py b/twophase/cubes/facecube.py deleted file mode 100644 index 593cca3..0000000 --- a/twophase/cubes/facecube.py +++ /dev/null @@ -1,111 +0,0 @@ -from ..pieces import Color, Facelet -from . import cubiecube - -# Maps corner positions to facelet positions -corner_facelet = ( - (Facelet.U9, Facelet.R1, Facelet.F3), - (Facelet.U7, Facelet.F1, Facelet.L3), - (Facelet.U1, Facelet.L1, Facelet.B3), - (Facelet.U3, Facelet.B1, Facelet.R3), - (Facelet.D3, Facelet.F9, Facelet.R7), - (Facelet.D1, Facelet.L9, Facelet.F7), - (Facelet.D7, Facelet.B9, Facelet.L7), - (Facelet.D9, Facelet.R9, Facelet.B7), -) - -# Maps edge positions to facelet positions -edge_facelet = ( - (Facelet.U6, Facelet.R2), - (Facelet.U8, Facelet.F2), - (Facelet.U4, Facelet.L2), - (Facelet.U2, Facelet.B2), - (Facelet.D6, Facelet.R8), - (Facelet.D2, Facelet.F8), - (Facelet.D4, Facelet.L8), - (Facelet.D8, Facelet.B8), - (Facelet.F6, Facelet.R4), - (Facelet.F4, Facelet.L6), - (Facelet.B6, Facelet.L4), - (Facelet.B4, Facelet.R6), -) - -# Maps corner positions to colours -corner_color = ( - (Color.U, Color.R, Color.F), - (Color.U, Color.F, Color.L), - (Color.U, Color.L, Color.B), - (Color.U, Color.B, Color.R), - (Color.D, Color.F, Color.R), - (Color.D, Color.L, Color.F), - (Color.D, Color.B, Color.L), - (Color.D, Color.R, Color.B), -) - -# Maps edge positions to colours -edge_color = ( - (Color.U, Color.R), - (Color.U, Color.F), - (Color.U, Color.L), - (Color.U, Color.B), - (Color.D, Color.R), - (Color.D, Color.F), - (Color.D, Color.L), - (Color.D, Color.B), - (Color.F, Color.R), - (Color.F, Color.L), - (Color.B, Color.L), - (Color.B, Color.R), -) - - -class FaceCube: - def __init__(self, cube_string="".join(c * 9 for c in "URFDLB")): - """ - Initialise FaceCube from cube_string, if cube_string is not provided we - initialise a clean cube. - """ - self.f = [0] * 54 - for i in range(54): - self.f[i] = Color[cube_string[i]] - - def to_string(self): - """Convert facecube to cubestring""" - return "".join(Color(i).name for i in self.f) - - def to_cubiecube(self): - """Convert FaceCube to CubieCube""" - cc = cubiecube.CubieCube() - for i in range(8): - # all corner names start with U or D, allowing us to find - # orientation of any given corner as follows - for ori in range(3): - if self.f[corner_facelet[i][ori]] in [Color.U, Color.D]: - break - color1 = self.f[corner_facelet[i][(ori + 1) % 3]] - color2 = self.f[corner_facelet[i][(ori + 2) % 3]] - for j in range(8): - if ( - color1 == corner_color[j][1] - and color2 == corner_color[j][2] - ): - cc.cp[i] = j - cc.co[i] = ori - break - - for i in range(12): - for j in range(12): - if ( - self.f[edge_facelet[i][0]] == edge_color[j][0] - and self.f[edge_facelet[i][1]] == edge_color[j][1] - ): - cc.ep[i] = j - cc.eo[i] = 0 - break - if ( - self.f[edge_facelet[i][0]] == edge_color[j][1] - and self.f[edge_facelet[i][1]] == edge_color[j][0] - ): - cc.ep[i] = j - cc.eo[i] = 1 - break - return cc diff --git a/twophase/random.py b/twophase/random.py deleted file mode 100644 index 425f2b5..0000000 --- a/twophase/random.py +++ /dev/null @@ -1,17 +0,0 @@ -import random - -from .cubes import cubiecube -from .tables import Tables - - -def random_cube(): - cc = cubiecube.CubieCube() - cc.flip = random.randint(0, Tables.FLIP) - cc.twist = random.randint(0, Tables.TWIST) - while True: - cc.corner = random.randint(0, Tables.CORNER) - cc.edge = random.randint(0, Tables.EDGE) - if cc.edge_parity == cc.corner_parity: - break - fc = cc.to_facecube() - return fc.to_string() diff --git a/twophase/solve.py b/twophase/solve.py deleted file mode 100644 index bf96376..0000000 --- a/twophase/solve.py +++ /dev/null @@ -1,255 +0,0 @@ -import time - -from .cubes import CoordCube, FaceCube -from .pieces import Color -from .tables import Tables - - -class SolutionManager: - def __init__(self, facelets): - """ - A utility class for managing the search for the solution. - - Parameters - ---------- - facelets: str - Starting position of the cube. Should be a 54 character string - specifying the stickers on each face (in order U R F D L B), - reading row by row from the top left hand corner to the bottom - right - """ - self.tables = Tables() - - self.facelets = facelets.upper() - - status = self.verify() - if status: - error_message = { - -1: "each colour should appear exactly 9 times", - -2: "not all edges exist exactly once", - -3: "one edge should be flipped", - -4: "not all corners exist exactly once", - -5: "one corner should be twisted", - -6: "two corners or edges should be exchanged", - } - raise ValueError("Invalid cube: {}".format(error_message[status])) - - def solve(self, max_length=25, timeout=float("inf")): - """ - Solve the cube. - - This method implements back to back IDA* searches for phase 1 and phase - 2, returning the result. Can be called multiple times with decreasing - max_length to try and find better solutions. - - Parameters - ---------- - max_length: int, optional - Upper bound for the allowed number of moves. - max_time: int or float, optional - Time at which to quit searching. Algorithm will quit when - ``time.time() > max_time``. - """ - # prepare for phase 1 - self._phase_1_initialise(max_length) - self._allowed_length = max_length - self._timeout = timeout - - for depth in range(self._allowed_length): - n = self._phase_1_search(0, depth) - if n >= 0: - # solution found - return self._solution_to_string(n) - elif n == -2: - # time limit exceeded - return -2 - - # no solution found - return -1 - - def verify(self): - count = [0] * 6 - try: - for char in self.facelets: - count[Color[char]] += 1 - except (IndexError, ValueError): - return -1 - for i in range(6): - if count[i] != 9: - return -1 - - fc = FaceCube(self.facelets) - cc = fc.to_cubiecube() - - return cc.verify() - - def _phase_1_initialise(self, max_length): - # the lists 'axis' and 'power' will store the nth move (index of face - # being turned stored in axis, number of clockwise quarter turns stored - # in power). The nth move is stored in position n-1 - self.axis = [0] * max_length - self.power = [0] * max_length - - # the lists twist, flip and udslice store the phase 1 coordinates after - # n moves. position 0 stores the inital states, the coordinates after n - # moves are stored in position n - self.twist = [0] * max_length - self.flip = [0] * max_length - self.udslice = [0] * max_length - - # similarly to above, these lists store the phase 2 coordinates after n - # moves. - self.corner = [0] * max_length - self.edge4 = [0] * max_length - self.edge8 = [0] * max_length - - # the following two arrays store minimum number of moves required to - # reach phase 2 or a solution respectively - # after n moves. these estimates come from the pruning tables and are - # used to exclude branches in the search tree. - self.min_dist_1 = [0] * max_length - self.min_dist_2 = [0] * max_length - - # initialise the arrays from the input - self.f = FaceCube(self.facelets) - self.c = CoordCube.from_cubiecube(self.f.to_cubiecube()) - self.twist[0] = self.c.twist - self.flip[0] = self.c.flip - self.udslice[0] = self.c.udslice - self.corner[0] = self.c.corner - self.edge4[0] = self.c.edge4 - self.edge8[0] = self.c.edge8 - self.min_dist_1[0] = self._phase_1_cost(0) - - def _phase_2_initialise(self, n): - if time.time() > self._timeout: - return -2 - # initialise phase 2 search from the phase 1 solution - cc = self.f.to_cubiecube() - for i in range(n): - for j in range(self.power[i]): - cc.move(self.axis[i]) - self.edge4[n] = cc.edge4 - self.edge8[n] = cc.edge8 - self.corner[n] = cc.corner - self.min_dist_2[n] = self._phase_2_cost(n) - for depth in range(self._allowed_length - n): - m = self._phase_2_search(n, depth) - if m >= 0: - return m - return -1 - - def _phase_1_cost(self, n): - """ - Cost of current position for use in phase 1. Returns a lower bound on - the number of moves requires to get to phase 2. - """ - return max( - self.tables.udslice_twist_prune[self.udslice[n], self.twist[n]], - self.tables.udslice_flip_prune[self.udslice[n], self.flip[n]], - ) - - def _phase_2_cost(self, n): - """ - Cost of current position for use in phase 2. Returns a lower bound on - the number of moves required to get to a solved cube. - """ - return max( - self.tables.edge4_corner_prune[self.edge4[n], self.corner[n]], - self.tables.edge4_edge8_prune[self.edge4[n], self.edge8[n]], - ) - - def _phase_1_search(self, n, depth): - if time.time() > self._timeout: - return -2 - elif self.min_dist_1[n] == 0: - return self._phase_2_initialise(n) - elif self.min_dist_1[n] <= depth: - for i in range(6): - if n > 0 and self.axis[n - 1] in (i, i + 3): - # don't turn the same face on consecutive moves - # also for opposite faces, e.g. U and D, UD = DU, so we can - # impose that the lower index happens first. - continue - for j in range(1, 4): - self.axis[n] = i - self.power[n] = j - mv = 3 * i + j - 1 - - # update coordinates - self.twist[n + 1] = self.tables.twist_move[self.twist[n]][ - mv - ] - self.flip[n + 1] = self.tables.flip_move[self.flip[n]][mv] - self.udslice[n + 1] = self.tables.udslice_move[ - self.udslice[n] - ][mv] - self.min_dist_1[n + 1] = self._phase_1_cost(n + 1) - - # start search from next node - m = self._phase_1_search(n + 1, depth - 1) - if m >= 0: - return m - if m == -2: - # time limit exceeded - return -2 - # if no solution found at current depth, return -1 - return -1 - - def _phase_2_search(self, n, depth): - if self.min_dist_2[n] == 0: - return n - elif self.min_dist_2[n] <= depth: - for i in range(6): - if n > 0 and self.axis[n - 1] in (i, i + 3): - continue - for j in range(1, 4): - if i in [1, 2, 4, 5] and j != 2: - # in phase two we only allow half turns of the faces - # R, F, L, B - continue - self.axis[n] = i - self.power[n] = j - mv = 3 * i + j - 1 - - # update coordinates following the move mv - self.edge4[n + 1] = self.tables.edge4_move[self.edge4[n]][ - mv - ] - self.edge8[n + 1] = self.tables.edge8_move[self.edge8[n]][ - mv - ] - self.corner[n + 1] = self.tables.corner_move[ - self.corner[n] - ][mv] - self.min_dist_2[n + 1] = self._phase_2_cost(n + 1) - - # start search from new node - m = self._phase_2_search(n + 1, depth - 1) - if m >= 0: - return m - # if no moves lead to a tree with a solution or min_dist_2 > depth then - # we return -1 to signify lack of solution - return -1 - - def _solution_to_string(self, length): - """ - Generate solution string. Uses standard cube notation: F means - clockwise quarter turn of the F face, U' means a counter clockwise - quarter turn of the U face, R2 means a half turn of the R face etc. - """ - - def recover_move(axis_power): - axis, power = axis_power - if power == 1: - return Color(axis).name - if power == 2: - return Color(axis).name + "2" - if power == 3: - return Color(axis).name + "'" - raise RuntimeError("Invalid move in solution.") - - solution = map( - recover_move, zip(self.axis[:length], self.power[:length]) - ) - return " ".join(solution) diff --git a/twophase/tables.py b/twophase/tables.py deleted file mode 100644 index b4e5cbb..0000000 --- a/twophase/tables.py +++ /dev/null @@ -1,278 +0,0 @@ -import json -import os - -from .cubes.cubiecube import MOVE_CUBE, CubieCube - - -class PruningTable: - """ - Helper class to allow pruning to be used as though they were 2-D tables - """ - - def __init__(self, table, stride): - self.table = table - self.stride = stride - - def __getitem__(self, x): - return self.table[x[0] * self.stride + x[1]] - - -class Tables: - """ - Class for holding move and pruning tables in memory. - - Move tables are used for updating coordinate representation of cube when a - particular move is applied. - - Pruning tables are used to obtain lower bounds for the number of moves - required to reach a solution given a particular pair of coordinates. - """ - - _tables_loaded = False - - # 3^7 possible corner orientations - TWIST = 2187 - # 2^11 possible edge flips - FLIP = 2048 - # 12C4 possible positions of FR, FL, BL, BR - UDSLICE = 495 - # 4! possible permutations of FR, FL, BL, BR - EDGE4 = 24 - # 8! possible permutations of UR, UF, UL, UB, DR, DF, DL, DB in phase two - EDGE8 = 40320 - # 8! possible permutations of the corners - CORNER = 40320 - # 12! possible permutations of all edges - EDGE = 479001600 - # 6*3 possible moves - MOVES = 18 - - def __init__(self): - if not self._tables_loaded: - self.load_tables() - - @classmethod - def load_tables(cls): - if os.path.isfile("tables.json"): - with open("tables.json", "r") as f: - tables = json.load(f) - cls.twist_move = tables["twist_move"] - cls.flip_move = tables["flip_move"] - cls.udslice_move = tables["udslice_move"] - cls.edge4_move = tables["edge4_move"] - cls.edge8_move = tables["edge8_move"] - cls.corner_move = tables["corner_move"] - cls.udslice_twist_prune = PruningTable( - tables["udslice_twist_prune"], cls.TWIST - ) - cls.udslice_flip_prune = PruningTable( - tables["udslice_flip_prune"], cls.FLIP - ) - cls.edge4_edge8_prune = PruningTable( - tables["edge4_edge8_prune"], cls.EDGE8 - ) - cls.edge4_corner_prune = PruningTable( - tables["edge4_corner_prune"], cls.CORNER - ) - else: - # ---------- Phase 1 move tables ---------- # - cls.twist_move = cls.make_twist_table() - cls.flip_move = cls.make_flip_table() - cls.udslice_move = cls.make_udslice_table() - - # ---------- Phase 2 move tables ---------- # - cls.edge4_move = cls.make_edge4_table() - cls.edge8_move = cls.make_edge8_table() - cls.corner_move = cls.make_corner_table() - - # ---------- Phase 1 pruning tables ---------- # - cls.udslice_twist_prune = cls.make_udslice_twist_prune() - cls.udslice_flip_prune = cls.make_udslice_flip_prune() - - # -------- Phase 2 pruning tables ---------- # - cls.edge4_edge8_prune = cls.make_edge4_edge8_prune() - cls.edge4_corner_prune = cls.make_edge4_corner_prune() - - tables = { - "twist_move": cls.twist_move, - "flip_move": cls.flip_move, - "udslice_move": cls.udslice_move, - "edge4_move": cls.edge4_move, - "edge8_move": cls.edge8_move, - "corner_move": cls.corner_move, - "udslice_twist_prune": cls.udslice_twist_prune.table, - "udslice_flip_prune": cls.udslice_flip_prune.table, - "edge4_edge8_prune": cls.edge4_edge8_prune.table, - "edge4_corner_prune": cls.edge4_corner_prune.table, - } - with open("tables.json", "w") as f: - json.dump(tables, f) - - cls._tables_loaded = True - - @classmethod - def make_twist_table(cls): - twist_move = [[0] * cls.MOVES for i in range(cls.TWIST)] - a = CubieCube() - for i in range(cls.TWIST): - a.twist = i - for j in range(6): - for k in range(3): - a.corner_multiply(MOVE_CUBE[j]) - twist_move[i][3 * j + k] = a.twist - a.corner_multiply(MOVE_CUBE[j]) - return twist_move - - @classmethod - def make_flip_table(cls): - flip_move = [[0] * cls.MOVES for i in range(cls.FLIP)] - a = CubieCube() - for i in range(cls.FLIP): - a.flip = i - for j in range(6): - for k in range(3): - a.edge_multiply(MOVE_CUBE[j]) - flip_move[i][3 * j + k] = a.flip - a.edge_multiply(MOVE_CUBE[j]) - return flip_move - - @classmethod - def make_udslice_table(cls): - udslice_move = [[0] * cls.MOVES for i in range(cls.UDSLICE)] - a = CubieCube() - for i in range(cls.UDSLICE): - a.udslice = i - for j in range(6): - for k in range(3): - a.edge_multiply(MOVE_CUBE[j]) - udslice_move[i][3 * j + k] = a.udslice - a.edge_multiply(MOVE_CUBE[j]) - return udslice_move - - @classmethod - def make_edge4_table(cls): - edge4_move = [[0] * cls.MOVES for i in range(cls.EDGE4)] - a = CubieCube() - for i in range(cls.EDGE4): - a.edge4 = i - for j in range(6): - for k in range(3): - a.edge_multiply(MOVE_CUBE[j]) - if k % 2 == 0 and j % 3 != 0: - edge4_move[i][3 * j + k] = -1 - else: - edge4_move[i][3 * j + k] = a.edge4 - a.edge_multiply(MOVE_CUBE[j]) - return edge4_move - - @classmethod - def make_edge8_table(cls): - edge8_move = [[0] * cls.MOVES for i in range(cls.EDGE8)] - a = CubieCube() - for i in range(cls.EDGE8): - a.edge8 = i - for j in range(6): - for k in range(3): - a.edge_multiply(MOVE_CUBE[j]) - if k % 2 == 0 and j % 3 != 0: - edge8_move[i][3 * j + k] = -1 - else: - edge8_move[i][3 * j + k] = a.edge8 - a.edge_multiply(MOVE_CUBE[j]) - return edge8_move - - @classmethod - def make_corner_table(cls): - corner_move = [[0] * cls.MOVES for i in range(cls.CORNER)] - a = CubieCube() - for i in range(cls.CORNER): - a.corner = i - for j in range(6): - for k in range(3): - a.corner_multiply(MOVE_CUBE[j]) - if k % 2 == 0 and j % 3 != 0: - corner_move[i][3 * j + k] = -1 - else: - corner_move[i][3 * j + k] = a.corner - a.corner_multiply(MOVE_CUBE[j]) - return corner_move - - @classmethod - def make_udslice_twist_prune(cls): - udslice_twist_prune = [-1] * (cls.UDSLICE * cls.TWIST) - udslice_twist_prune[0] = 0 - count, depth = 1, 0 - while count < cls.UDSLICE * cls.TWIST: - for i in range(cls.UDSLICE * cls.TWIST): - if udslice_twist_prune[i] == depth: - m = [ - cls.udslice_move[i // cls.TWIST][j] * cls.TWIST - + cls.twist_move[i % cls.TWIST][j] - for j in range(18) - ] - for x in m: - if udslice_twist_prune[x] == -1: - count += 1 - udslice_twist_prune[x] = depth + 1 - depth += 1 - return PruningTable(udslice_twist_prune, cls.TWIST) - - @classmethod - def make_udslice_flip_prune(cls): - udslice_flip_prune = [-1] * (cls.UDSLICE * cls.FLIP) - udslice_flip_prune[0] = 0 - count, depth = 1, 0 - while count < cls.UDSLICE * cls.FLIP: - for i in range(cls.UDSLICE * cls.FLIP): - if udslice_flip_prune[i] == depth: - m = [ - cls.udslice_move[i // cls.FLIP][j] * cls.FLIP - + cls.flip_move[i % cls.FLIP][j] - for j in range(18) - ] - for x in m: - if udslice_flip_prune[x] == -1: - count += 1 - udslice_flip_prune[x] = depth + 1 - depth += 1 - return PruningTable(udslice_flip_prune, cls.FLIP) - - @classmethod - def make_edge4_edge8_prune(cls): - edge4_edge8_prune = [-1] * (cls.EDGE4 * cls.EDGE8) - edge4_edge8_prune[0] = 0 - count, depth = 1, 0 - while count < cls.EDGE4 * cls.EDGE8: - for i in range(cls.EDGE4 * cls.EDGE8): - if edge4_edge8_prune[i] == depth: - m = [ - cls.edge4_move[i // cls.EDGE8][j] * cls.EDGE8 - + cls.edge8_move[i % cls.EDGE8][j] - for j in range(18) - ] - for x in m: - if edge4_edge8_prune[x] == -1: - count += 1 - edge4_edge8_prune[x] = depth + 1 - depth += 1 - return PruningTable(edge4_edge8_prune, cls.EDGE8) - - @classmethod - def make_edge4_corner_prune(cls): - edge4_corner_prune = [-1] * (cls.EDGE4 * cls.CORNER) - edge4_corner_prune[0] = 0 - count, depth = 1, 0 - while count < cls.EDGE4 * cls.CORNER: - for i in range(cls.EDGE4 * cls.CORNER): - if edge4_corner_prune[i] == depth: - m = [ - cls.edge4_move[i // cls.CORNER][j] * cls.CORNER - + cls.corner_move[i % cls.CORNER][j] - for j in range(18) - ] - for x in m: - if edge4_corner_prune[x] == -1: - count += 1 - edge4_corner_prune[x] = depth + 1 - depth += 1 - return PruningTable(edge4_corner_prune, cls.CORNER) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..535b7c2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,151 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "twophase" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "tqdm" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [{ name = "tqdm", specifier = ">=4.66.5" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.0" }, + { name = "ruff", specifier = ">=0.11.13" }, + { name = "ty", specifier = ">=0.0.21" }, +] + +[[package]] +name = "ty" +version = "0.0.75" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/d0/d0c96f898d6974a4a3569ab3efdf9512c04ad99f9203effb55f72497fe97/ty-0.0.75.tar.gz", hash = "sha256:4c5eead33dfbf6e2ebb4f400f74b51ffc9bab702a6f23ddb648a1cbb740387e3", size = 6868326, upload-time = "2026-08-26T20:23:40.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/6c/b12d03505f17581f0cfa3c12273fe34c1d67b36dfda1bc561a6bdc16512b/ty-0.0.75-py3-none-linux_armv6l.whl", hash = "sha256:e5409f50db2246fd4bd039d93d261e0cfa1daa554a4fb77256f91072c570349a", size = 12972606, upload-time = "2026-08-26T20:22:59.716Z" }, + { url = "https://files.pythonhosted.org/packages/d1/aa/30f11eecd9215a9f87e8fe8baaf48f3ce905f5d75b8e4aac70f0091f130c/ty-0.0.75-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5e7b8b3472fb9bb2eeab314984b265df08a7a9d518867a9e6020eebc06570be2", size = 12527158, upload-time = "2026-08-26T20:23:02.767Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/7fd7001b0b5c6610bfbad7357e47d5fe6f82d4e84e94c53776a478f5e9f8/ty-0.0.75-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c6ccf34169821fe0d23e3360deeef981d217963412f1d087b9bdd32ec57f7a57", size = 12400533, upload-time = "2026-08-26T20:23:04.965Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7f/1e284ea3d348d7be02f12d83bc22ed9ef193033f863f05b64db99027f141/ty-0.0.75-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:842ebb41e9c6c334b40768704e20b1a69d5c6b08805b289d5e0e2565f49f2de1", size = 12420592, upload-time = "2026-08-26T20:23:07.427Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ab/d813271543370c47fd74b5118f2066ab32b0983e907b1821f3f9a6d0fa7f/ty-0.0.75-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf7a5a723c5f1e0fab4ffbfe9bd95123a526ed48f206e5f25cb2161ca294007a", size = 12739219, upload-time = "2026-08-26T20:23:09.809Z" }, + { url = "https://files.pythonhosted.org/packages/31/5b/95b49cc5570fd92a7bf63732f649b31906158721e03c7fcb1b5be74ee3bf/ty-0.0.75-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54382f98e5da292fcd7104391afef5105c35bb2f312e29bea6f5fa419935255c", size = 13494046, upload-time = "2026-08-26T20:23:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0d/502d2dd68173cf020e1ad2bdbab9544c86776de0b0e2ed15f8c2fe006e3d/ty-0.0.75-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac13b180dc2aade2cd243f56b01650e78bf091a2e522ad3bc947245d7837c613", size = 13938899, upload-time = "2026-08-26T20:23:14.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/5b/f3b12a25c07224456219fc2bd20db0ad7e40b304be0ff6aad728da0135f9/ty-0.0.75-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752df7951a443219d7f1ff817e3723c85d428565ff449e08a7a93ba821661526", size = 13656711, upload-time = "2026-08-26T20:23:17.145Z" }, + { url = "https://files.pythonhosted.org/packages/51/7b/f090ad306e2b15a07b332d647138c5264b89d9758855ecce8b8a10bcb153/ty-0.0.75-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fd399feedf7cee816563c1baec45fc1c0b3c89f1ea42364920b688004b5b7da", size = 13093499, upload-time = "2026-08-26T20:23:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4b/f69b99aaaca0c7c65d5f114b186b26b21666f767b0c69eec99a2bdccc061/ty-0.0.75-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:d7625f6f56c7dc1e873579fdc9e432a0e21e302afe847ab60704d2303442a92e", size = 13520580, upload-time = "2026-08-26T20:23:21.789Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e7/692c5f905c0345a15d2255fc74066d660f030254ae8dcdaf33f5a5c2f279/ty-0.0.75-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:89e7d527e95a2534b70cae29e94c104b84082760ea05927d23bb87280969c104", size = 12524095, upload-time = "2026-08-26T20:23:24.026Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9a/f42b12cf265ea95344bf554764c4791cfb273bdd628aadd7c209af7cadc3/ty-0.0.75-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0843f134440740706e01bee5f88f4cfc10e9b018bddb9e4ef4c12dc9fc0c9aef", size = 12756591, upload-time = "2026-08-26T20:23:26.126Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5e/9b180c133cb9cce48179a7d2bf9e1802d992aa8176a918e0e05205760b42/ty-0.0.75-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1bd0ec0e50ee1376875c88891efe6f549c3560fa5b2ddad79a425cd5a6218b9c", size = 12998754, upload-time = "2026-08-26T20:23:28.353Z" }, + { url = "https://files.pythonhosted.org/packages/39/f6/3c6ef5dd550103e29905121c67fb96a374564f31a2f44c6faa1af98c2d61/ty-0.0.75-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f9eafd561f90110d5e29f589ec3e956c4686e2f6631348d99276436f5cbe4d1", size = 13316474, upload-time = "2026-08-26T20:23:30.857Z" }, + { url = "https://files.pythonhosted.org/packages/bb/52/12776337874c821076bd5368e352ccd9e67174790abe3b856f749cb3524b/ty-0.0.75-py3-none-win32.whl", hash = "sha256:05063a6fafe2154b794a7f964515d148e51acd186d72d4a3acd347ee9fa19336", size = 12316315, upload-time = "2026-08-26T20:23:33.528Z" }, + { url = "https://files.pythonhosted.org/packages/53/e6/bb51e16af5c7138c9f52f8f3d0a401a371c6798d092e3b74926f186a9814/ty-0.0.75-py3-none-win_amd64.whl", hash = "sha256:81cf1ba5f6b7536ad56747865214255d9bc8e80533a689dbb9ddeaad464b09f1", size = 12917267, upload-time = "2026-08-26T20:23:35.978Z" }, + { url = "https://files.pythonhosted.org/packages/39/73/4542f829107468b5de4231af67f29927c093bfad11f3c1e5b2c08fb1206b/ty-0.0.75-py3-none-win_arm64.whl", hash = "sha256:541c9af5b7a0ad23d15ec315a7da81150833c359f48124ed3789ff25eacd6f42", size = 12711024, upload-time = "2026-08-26T20:23:38.159Z" }, +] From d6bf7bc3b77587fd7206a5c1bcdfde0bd0d7c17b Mon Sep 17 00:00:00 2001 From: tcbegley Date: Mon, 31 Aug 2026 08:15:50 +0100 Subject: [PATCH 2/6] Add coordinate tables and cube symmetries --- src/twophase/tables/__init__.py | 6 +- src/twophase/tables/_array.py | 84 ++++++ src/twophase/tables/_cache.py | 18 ++ src/twophase/tables/generators/__init__.py | 47 ++++ src/twophase/tables/generators/conj.py | 55 ++++ src/twophase/tables/generators/move.py | 71 +++++ src/twophase/tables/generators/pruning.py | 302 +++++++++++++++++++++ src/twophase/tables/generators/search.py | 45 +++ src/twophase/tables/generators/symmetry.py | 79 ++++++ src/twophase/tables/symmetries.py | 196 +++++++++++++ src/twophase/tables/tables.py | 261 ++++++++++++++++++ 11 files changed, 1160 insertions(+), 4 deletions(-) create mode 100644 src/twophase/tables/_array.py create mode 100644 src/twophase/tables/_cache.py create mode 100644 src/twophase/tables/generators/__init__.py create mode 100644 src/twophase/tables/generators/conj.py create mode 100644 src/twophase/tables/generators/move.py create mode 100644 src/twophase/tables/generators/pruning.py create mode 100644 src/twophase/tables/generators/search.py create mode 100644 src/twophase/tables/generators/symmetry.py create mode 100644 src/twophase/tables/symmetries.py create mode 100644 src/twophase/tables/tables.py diff --git a/src/twophase/tables/__init__.py b/src/twophase/tables/__init__.py index 76db90e..054a403 100644 --- a/src/twophase/tables/__init__.py +++ b/src/twophase/tables/__init__.py @@ -1,5 +1,3 @@ -"""Cube model and, later, the lookup tables used by the solver.""" +from twophase.tables.tables import delete_tables, make_or_load_tables -from twophase.tables.cubiecube import CubieCube, Moves, move, verify - -__all__ = ["CubieCube", "Moves", "move", "verify"] +__all__ = ["delete_tables", "make_or_load_tables"] diff --git a/src/twophase/tables/_array.py b/src/twophase/tables/_array.py new file mode 100644 index 0000000..57e5f54 --- /dev/null +++ b/src/twophase/tables/_array.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import array +from enum import StrEnum +from typing import Iterator + + +class dtypes(StrEnum): + """ + Map system dependent array types to more understandable types. + """ + + u8 = "B" + u16 = "H" + u32 = "I" if array.array("I").itemsize >= 4 else "L" + + +class BitpackedArray: + """ + A lightweight wrapper around a 32-bit array that can be used to store 16 two bit + values in each 32-bit entry of the underlying array. + """ + + def __init__(self, dim: int) -> None: + self._array = array.array(dtypes.u32, [0xFFFF_FFFF] * ((dim - 1) // 16 + 1)) + + @classmethod + def frombytes(cls, data: bytes) -> BitpackedArray: + bitpacked_array = cls(0) + bitpacked_array._array.frombytes(data) + return bitpacked_array + + def tobytes(self) -> bytes: + return self._array.tobytes() + + def __getitem__(self, index: int) -> int: + """ + Helper function for retrieving values from an array where each entry is 32-bit + unsigned integer that represents 16 2-bit integers. + """ + # Each entry is a block of 16 two-bit values. Bit operations avoid creating + # the tuple returned by divmod in this search-critical lookup. + block = self._array[index >> 4] + bit_shift = (index & 0b1111) << 1 + return (block >> bit_shift) & 0b11 + + def __setitem__(self, index: int, value: int) -> None: + """ + Helper function for setting values in an array where each entry is a 32-bit + unsigned integer that represents 16 2-bit integers + """ + # see get_index for explanation + block_index, bitpair_offset = divmod(index, 16) + bit_shift = bitpair_offset * 2 + # we first zero the bits we are writing + self._array[block_index] &= ~(0b11 << bit_shift) & 0xFFFF_FFFF + # then we shift the supplied value and set it in the relevant block + self._array[block_index] |= value << bit_shift + + def __len__(self) -> int: + return len(self._array) * 16 + + def __iter__(self) -> Iterator[int]: + return iter(self[i] for i in range(len(self))) + + +def max_val_to_dtype(max_val: int) -> dtypes: + # we check against max val -1 so that we can reserve the largest value as an + # "invalid" marker + if max_val < 2**8 - 1: + return dtypes.u8 + elif max_val < 2**16 - 1: + return dtypes.u16 + return dtypes.u32 + + +def dtype_to_max_val(dtype: dtypes) -> int: + match dtype: + case dtypes.u8: + return 2**8 - 1 + case dtypes.u16: + return 2**16 - 1 + case dtypes.u32: + return 2**32 - 1 diff --git a/src/twophase/tables/_cache.py b/src/twophase/tables/_cache.py new file mode 100644 index 0000000..b41eaa9 --- /dev/null +++ b/src/twophase/tables/_cache.py @@ -0,0 +1,18 @@ +from importlib.resources import files +from pathlib import Path +from typing import cast + + +def resolve_cache_path(path: str | Path | None) -> Path: + """ + Cast supplied cache path to pathlib.Path, or default to internal cache and make + sure directory exists. + """ + if path is not None: + path = Path(path) + if path.is_file(): + raise ValueError("Cache path must be a directory...") + else: + path = cast(Path, files("twophase")) / "tables" / ".cache" + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/src/twophase/tables/generators/__init__.py b/src/twophase/tables/generators/__init__.py new file mode 100644 index 0000000..a7b8e66 --- /dev/null +++ b/src/twophase/tables/generators/__init__.py @@ -0,0 +1,47 @@ +from twophase.tables.generators.conj import ( + make_edge8_conj_table, + make_move_conj_table, + make_twist_conj_table, +) +from twophase.tables.generators.move import ( + make_corner_move_table, + make_d_edges_sorted_move_table, + make_edge8_move_table, + make_flip_move_table, + make_twist_move_table, + make_u_edges_sorted_move_table, + make_ud_slice_sorted_move_table, +) +from twophase.tables.generators.pruning import ( + make_corner_class_edge8_prune_table, + make_cornslice_prune_table, + make_flip_ud_slice_class_twist_prune_table, +) +from twophase.tables.generators.search import ( + make_depth_lookup_table, + make_edge_merge_table, +) +from twophase.tables.generators.symmetry import ( + make_corner_symmetry_tables, + make_flip_ud_slice_symmetry_tables, +) + +__all__ = [ + "make_corner_class_edge8_prune_table", + "make_corner_move_table", + "make_cornslice_prune_table", + "make_corner_symmetry_tables", + "make_d_edges_sorted_move_table", + "make_depth_lookup_table", + "make_edge_merge_table", + "make_edge8_conj_table", + "make_edge8_move_table", + "make_flip_move_table", + "make_flip_ud_slice_class_twist_prune_table", + "make_flip_ud_slice_symmetry_tables", + "make_move_conj_table", + "make_twist_conj_table", + "make_twist_move_table", + "make_u_edges_sorted_move_table", + "make_ud_slice_sorted_move_table", +] diff --git a/src/twophase/tables/generators/conj.py b/src/twophase/tables/generators/conj.py new file mode 100644 index 0000000..131681f --- /dev/null +++ b/src/twophase/tables/generators/conj.py @@ -0,0 +1,55 @@ +import array + +from tqdm.auto import tqdm + +from twophase.constants import EDGE8_MAX, MOVES, SYMMETRIES, SYMMETRIES_D4H, TWIST_MAX +from twophase.tables._array import dtypes, max_val_to_dtype +from twophase.tables.cubiecube import CubieCube, Moves +from twophase.tables.symmetries import Symmetries + + +# lookup table for symmetry conjugated moves +def make_move_conj_table() -> array.array: + symmetries = Symmetries() + moves = Moves() + + table = array.array(dtypes.u8, [0] * MOVES * SYMMETRIES) + for i, sym in enumerate(symmetries): + for j, mv in enumerate(moves): + conj_mv = sym * mv * symmetries.inverse[i] + for k, mv2 in enumerate(moves): + if conj_mv == mv2: + table[MOVES * i + j] = k + + return table + + +def _make_conj_table(name: str, max_val: int, edge: bool) -> array.array: + table = array.array(max_val_to_dtype(max_val), [0] * SYMMETRIES_D4H * max_val) + d4h_symmetries = Symmetries(subgroup=True) + + cube = CubieCube() + pbar = tqdm(total=SYMMETRIES_D4H * max_val, desc=f"{name} conj table") + + for coord in range(max_val): + setattr(cube, name, coord) + for s, sym in enumerate(d4h_symmetries): + if edge: + conj_cube = sym.edge_multiply( + cube.edge_multiply(d4h_symmetries.inverse[s]) + ) + else: + conj_cube = sym.corner_multiply( + cube.corner_multiply(d4h_symmetries.inverse[s]) + ) + table[SYMMETRIES_D4H * coord + s] = getattr(conj_cube, name) + pbar.update(SYMMETRIES_D4H) + return table + + +def make_twist_conj_table() -> array.array: + return _make_conj_table("twist", TWIST_MAX, edge=False) + + +def make_edge8_conj_table() -> array.array: + return _make_conj_table("edge8", EDGE8_MAX, edge=True) diff --git a/src/twophase/tables/generators/move.py b/src/twophase/tables/generators/move.py new file mode 100644 index 0000000..b87c13e --- /dev/null +++ b/src/twophase/tables/generators/move.py @@ -0,0 +1,71 @@ +import array + +from tqdm import tqdm + +from twophase import constants +from twophase.tables._array import max_val_to_dtype +from twophase.tables.cubiecube import CubieCube, move + + +def _make_move_table( + name: str, max_val: int, edge: bool, phase2: bool = False +) -> array.array: + dtype = max_val_to_dtype(max_val) + table = array.array(dtype, [0] * constants.MOVES * max_val) + cube = CubieCube() + pbar = tqdm(total=constants.MOVES * max_val, desc=f"{name} table") + for i in range(max_val): + setattr(cube, name, i) + for mv in range(6): + for rot in range(3): + cube = move(cube, mv, corner=not edge, edge=edge) + if phase2 and mv % 3 != 0 and rot % 2 == 0: + # in phase 2, only 180 degree turns of the faces R F L B are valid + # for some coordinates used in phase 2, making a different turn is + # non-sensical, so we skip and leave the entries as 0 + continue + table[constants.MOVES * i + 3 * mv + rot] = getattr(cube, name) + cube = move(cube, mv, corner=not edge, edge=edge) + pbar.update(constants.MOVES) + return table + + +# used in phase 1 +def make_twist_move_table() -> array.array: + return _make_move_table("twist", constants.TWIST_MAX, edge=False) + + +# phase 1 +def make_flip_move_table() -> array.array: + return _make_move_table("flip", constants.FLIP_MAX, edge=True) + + +# phase 1 and phase 2 +def make_ud_slice_sorted_move_table() -> array.array: + return _make_move_table( + "ud_slice_sorted", constants.UD_SLICE_MAX * constants.EDGE4_MAX, edge=True + ) + + +# phase 1 -> 2 transition +def make_u_edges_sorted_move_table() -> array.array: + return _make_move_table( + "u_edges_sorted", constants.UD_SLICE_MAX * constants.EDGE4_MAX, edge=True + ) + + +# phase 1 -> 2 transition +def make_d_edges_sorted_move_table() -> array.array: + return _make_move_table( + "d_edges_sorted", constants.UD_SLICE_MAX * constants.EDGE4_MAX, edge=True + ) + + +# phase 2 +def make_edge8_move_table() -> array.array: + return _make_move_table("edge8", constants.EDGE8_MAX, edge=True, phase2=True) + + +# phase 2 +def make_corner_move_table() -> array.array: + return _make_move_table("corner", constants.CORNER_MAX, edge=False) diff --git a/src/twophase/tables/generators/pruning.py b/src/twophase/tables/generators/pruning.py new file mode 100644 index 0000000..c7acf33 --- /dev/null +++ b/src/twophase/tables/generators/pruning.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import array +from collections import deque + +from tqdm.auto import tqdm + +from twophase.constants import ( + CORNER_CLASSES, + CORNER_MAX, + EDGE4_MAX, + EDGE8_MAX, + FLIP_MAX, + FLIP_UD_SLICE_CLASSES, + MOVES, + PHASE_1_MOVES, + PHASE_2_MOVES, + SYMMETRIES_D4H, + TWIST_MAX, +) +from twophase.tables._array import BitpackedArray, dtypes +from twophase.tables.cubiecube import CubieCube +from twophase.tables.symmetries import Symmetries + + +def _get_coord_preserving_symmetries( + name: str, n_classes: int, class_representative_lookup: array.array, edge: bool +) -> array.array: + # this table tracks which symmetries preserve the flip_ud_slice coordinate for each + # equivalence class. use a sixteen bit binary number where each bit acts as a flag + # for the ith symmetry + coord_preserving_symmetries = array.array(dtypes.u16, [0] * n_classes) + symmetries = Symmetries(subgroup=True) + cube = CubieCube() + + for class_idx in range(n_classes): + coord = class_representative_lookup[class_idx] + setattr(cube, name, coord) + for s, symmetry in enumerate(symmetries): + if edge: + conj_cube = symmetry.edge_multiply( + cube.edge_multiply(symmetries.inverse[s]) + ) + else: + conj_cube = symmetry.corner_multiply( + cube.corner_multiply(symmetries.inverse[s]) + ) + if getattr(conj_cube, name) == coord: + coord_preserving_symmetries[class_idx] |= 1 << s + + return coord_preserving_symmetries + + +def _forward_search( + table: BitpackedArray, + pbar: tqdm, + coord1_move: array.array | FlipUDSliceMoveTable, + coord2_move: array.array, + coord1_class_lookup: array.array, + coord1_symmetry_lookup: array.array, + coord1_representative_lookup: array.array, + coord2_conj: array.array, + coord2_max: int, + coord_preserving_symmetries: array.array, + max_depth: int, + phase_2: bool = False, +) -> int: + moves_iter = PHASE_2_MOVES if phase_2 else PHASE_1_MOVES + table[0] = 0 + count = 1 + queue = deque([(0, 0)]) + + while queue: + coord1_class_coord2, depth = queue.popleft() + + coord1_class, coord2 = divmod(coord1_class_coord2, coord2_max) + coord1 = coord1_representative_lookup[coord1_class] + + for mv in moves_iter: + coord11 = coord1_move[MOVES * coord1 + mv] + coord1_class1 = coord1_class_lookup[coord11] + coord1_sym1 = coord1_symmetry_lookup[coord11] + coord21 = coord2_conj[ + SYMMETRIES_D4H * coord2_move[MOVES * coord2 + mv] + coord1_sym1 + ] + coord1_class_coord21 = coord2_max * coord1_class1 + coord21 + + if table[coord1_class_coord21] == 0b11: + table[coord1_class_coord21] = (depth + 1) % 3 + if depth < max_depth: + queue.append((coord1_class_coord21, depth + 1)) + count += 1 + pbar.update(1) + + sym = coord_preserving_symmetries[coord1_class1] + if sym == 1: + continue + + for k in range(1, 16): + sym >>= 1 + if sym % 2 == 1: + coord22 = coord2_conj[SYMMETRIES_D4H * coord21 + k] + coord1_class_coord22 = coord2_max * coord1_class1 + coord22 + if table[coord1_class_coord22] == 0b11: + table[coord1_class_coord22] = (depth + 1) % 3 + if depth < max_depth: + queue.append((coord1_class_coord22, depth + 1)) + count += 1 + pbar.update(1) + + return count + + +def _backward_search( + table: BitpackedArray, + pbar: tqdm, + coord1_move: array.array | FlipUDSliceMoveTable, + coord2_move: array.array, + coord1_class_lookup: array.array, + coord1_symmetry_lookup: array.array, + coord1_representative_lookup: array.array, + coord2_conj: array.array, + coord2_max: int, + depth: int, + count: int, + total: int, +) -> None: + while count < total: + depth3 = depth % 3 + for coord1_class_coord2 in range(total): + if table[coord1_class_coord2] != 0b11: + continue + + coord1_class, coord2 = divmod(coord1_class_coord2, coord2_max) + coord1 = coord1_representative_lookup[coord1_class] + + for mv in PHASE_1_MOVES: + coord11 = coord1_move[MOVES * coord1 + mv] + coord1_class1 = coord1_class_lookup[coord11] + coord1_sym1 = coord1_symmetry_lookup[coord11] + coord21 = coord2_conj[ + SYMMETRIES_D4H * coord2_move[MOVES * coord2 + mv] + coord1_sym1 + ] + coord1_class_coord21 = coord2_max * coord1_class1 + coord21 + + if table[coord1_class_coord21] == depth3: + table[coord1_class_coord2] = (depth + 1) % 3 + pbar.update(1) + count += 1 + break + depth += 1 + + +class FlipUDSliceMoveTable: + """ + Mimics a flip_ud_slice_move table without actually computing all the entries. + """ + + def __init__(self, flip_move, ud_slice_sorted_move): + self.flip_move = flip_move + self.ud_slice_sorted_move = ud_slice_sorted_move + + def __getitem__(self, flip_ud_slice_move): + flip_ud_slice, move = divmod(flip_ud_slice_move, MOVES) + ud_slice, flip = divmod(flip_ud_slice, FLIP_MAX) + flip_new = self.flip_move[MOVES * flip + move] + ud_slice_new = ( + self.ud_slice_sorted_move[MOVES * EDGE4_MAX * ud_slice + move] // EDGE4_MAX + ) + return FLIP_MAX * ud_slice_new + flip_new + + +def make_flip_ud_slice_class_twist_prune_table( + twist_move: array.array, + flip_move: array.array, + ud_slice_sorted_move: array.array, + flip_ud_slice_class_lookup: array.array, + flip_ud_slice_symmetry_lookup: array.array, + flip_ud_slice_representative_lookup: array.array, + twist_conj: array.array, +) -> BitpackedArray: + total = FLIP_UD_SLICE_CLASSES * TWIST_MAX + table = BitpackedArray(total) + + # this table tracks which symmetries preserve the flip_ud_slice coordinate for each + # equivalence class + coord_preserving_symmetries = _get_coord_preserving_symmetries( + "flip_ud_slice", + FLIP_UD_SLICE_CLASSES, + flip_ud_slice_representative_lookup, + edge=True, + ) + + pbar = tqdm(total=total, desc="flip_ud_slice_class_twist prune table") + flip_ud_slice_move = FlipUDSliceMoveTable(flip_move, ud_slice_sorted_move) + + # forward search + count = _forward_search( + table=table, + pbar=pbar, + coord1_move=flip_ud_slice_move, + coord2_move=twist_move, + coord1_class_lookup=flip_ud_slice_class_lookup, + coord1_symmetry_lookup=flip_ud_slice_symmetry_lookup, + coord1_representative_lookup=flip_ud_slice_representative_lookup, + coord2_conj=twist_conj, + coord2_max=TWIST_MAX, + coord_preserving_symmetries=coord_preserving_symmetries, + max_depth=8, + ) + + # backward search + _backward_search( + table=table, + pbar=pbar, + coord1_move=flip_ud_slice_move, + coord2_move=twist_move, + coord1_class_lookup=flip_ud_slice_class_lookup, + coord1_symmetry_lookup=flip_ud_slice_symmetry_lookup, + coord1_representative_lookup=flip_ud_slice_representative_lookup, + coord2_conj=twist_conj, + coord2_max=TWIST_MAX, + depth=9, + count=count, + total=total, + ) + + return table + + +def make_corner_class_edge8_prune_table( + corner_move: array.array, + edge8_move: array.array, + corner_class_lookup: array.array, + corner_symmetry_lookup: array.array, + corner_representative_lookup: array.array, + edge8_conj: array.array, + max_depth: int = 9, +) -> BitpackedArray: + total = CORNER_CLASSES * EDGE8_MAX + table = BitpackedArray(total) + + coord_preserving_symmetries = _get_coord_preserving_symmetries( + "corner", CORNER_CLASSES, corner_representative_lookup, edge=False + ) + pbar = tqdm(total=total, desc="corner_class_edge8 prune table") + + _forward_search( + table, + pbar, + corner_move, + edge8_move, + corner_class_lookup, + corner_symmetry_lookup, + corner_representative_lookup, + edge8_conj, + EDGE8_MAX, + coord_preserving_symmetries, + max_depth, + phase_2=True, + ) + + return table + + +def make_cornslice_prune_table( + corner_move: array.array, ud_slice_sorted_move: array.array +) -> array.array: + """ + Pruning table storing exact depths for (corner, edge4) coordinate pairs using + phase 2 moves. Used as an additional pruning check during phase 2 search. + + Unlike the other pruning tables this stores exact depths (not mod 3) + in a regular array, since the coordinate space is small enough + (CORNER_MAX * EDGE4_MAX = 967,680). + """ + total = CORNER_MAX * EDGE4_MAX + table = array.array(dtypes.u16, [0xFFFF] * total) + table[0] = 0 + + done = 1 + depth = 0 + pbar = tqdm(total=total, desc="cornslice prune table") + pbar.update(1) + + while done < total: + for corner in range(CORNER_MAX): + for edge4 in range(EDGE4_MAX): + idx = EDGE4_MAX * corner + edge4 + if table[idx] != depth: + continue + for mv in PHASE_2_MOVES: + corner1 = corner_move[MOVES * corner + mv] + edge4_1 = ud_slice_sorted_move[MOVES * edge4 + mv] + idx1 = EDGE4_MAX * corner1 + edge4_1 + if table[idx1] == 0xFFFF: + table[idx1] = depth + 1 + done += 1 + pbar.update(1) + depth += 1 + + return table diff --git a/src/twophase/tables/generators/search.py b/src/twophase/tables/generators/search.py new file mode 100644 index 0000000..2f5f328 --- /dev/null +++ b/src/twophase/tables/generators/search.py @@ -0,0 +1,45 @@ +import array + +from tqdm import trange + +from twophase.constants import EDGE4_MAX, EDGE8_MAX +from twophase.tables._array import dtypes, max_val_to_dtype +from twophase.tables.cubiecube import CubieCube + + +def make_edge_merge_table() -> array.array: + """ + The edge_merge table is used in phase 2 to map (u_edges_sorted, d_edges_sorted) to + edge8. This is possible because in phase 2 the u edges and d edges are necessarily + in the u and d layers. + """ + dtype = max_val_to_dtype(EDGE8_MAX) + table = array.array(dtype, [0] * EDGE8_MAX) + + cube = CubieCube() + for i in trange(EDGE8_MAX, desc="edge_merge table"): + cube.edge8 = i + table[EDGE4_MAX * cube.u_edges_sorted + cube.d_edges_sorted % EDGE4_MAX] = i + + return table + + +def make_depth_lookup_table() -> array.array: + """ + The depth_lookup table is used during search. The pruning tables only store the + depth mod 3, so it's convenient to have a fast way to look up the new min depth + from the old depth + the new depth mod 3. This is possible since depth can only + change by ±1 (or 0) with each move. + """ + table = array.array(dtypes.u8, [0] * 20 * 3) + + for depth in range(20): + for depth3 in range(3): + if depth % 3 == 0 and depth3 == 2: + table[3 * depth + depth3] = max(depth - 1, 0) + elif depth % 3 == 2 and depth3 == 0: + table[3 * depth + depth3] = depth + 1 + else: + table[3 * depth + depth3] = depth + (depth3 - depth % 3) + + return table diff --git a/src/twophase/tables/generators/symmetry.py b/src/twophase/tables/generators/symmetry.py new file mode 100644 index 0000000..4802d66 --- /dev/null +++ b/src/twophase/tables/generators/symmetry.py @@ -0,0 +1,79 @@ +import array + +from tqdm import tqdm + +from twophase.constants import ( + CORNER_CLASSES, + CORNER_MAX, + FLIP_MAX, + FLIP_UD_SLICE_CLASSES, + SYMMETRIES_D4H, + UD_SLICE_MAX, +) +from twophase.tables._array import dtype_to_max_val, dtypes, max_val_to_dtype +from twophase.tables.cubiecube import CubieCube +from twophase.tables.symmetries import Symmetries + + +def _make_symmetry_tables( + name: str, max_val: int, num_classes: int, edge: bool +) -> tuple[array.array, array.array, array.array]: + class_dtype = max_val_to_dtype(num_classes) + coord_dtype = max_val_to_dtype(max_val) + + class_max = dtype_to_max_val(class_dtype) + + class_lookup = array.array(class_dtype, [class_max] * max_val) + symmetry_lookup = array.array(dtypes.u8, [dtype_to_max_val(dtypes.u8)] * max_val) + representative_lookup = array.array( + coord_dtype, [dtype_to_max_val(coord_dtype)] * num_classes + ) + + symmetries = Symmetries(subgroup=True) + + class_index = 0 + cube = CubieCube() + + pbar = tqdm(total=SYMMETRIES_D4H * max_val, desc=f"{name} symmetry tables") + + for coord in range(max_val): + if class_lookup[coord] == class_max: + # this is the first time we've encountered the value coord, we'll make this + # cube the representative, and add all equivalent cubes to the class by + # conjugating with all of the possible symmetries + class_lookup[coord] = class_index + symmetry_lookup[coord] = 0 + representative_lookup[class_index] = coord + + setattr(cube, name, coord) + + for symmetry_index, symmetry in enumerate(symmetries): + if edge: + conj_cube = symmetries.inverse[symmetry_index].edge_multiply( + cube.edge_multiply(symmetry) + ) + else: + conj_cube = symmetries.inverse[symmetry_index].corner_multiply( + cube.corner_multiply(symmetry) + ) + coord_new = getattr(conj_cube, name) + if class_lookup[coord_new] == class_max: + class_lookup[coord_new] = class_index + symmetry_lookup[coord_new] = symmetry_index + class_index += 1 + + pbar.update(SYMMETRIES_D4H) + + return class_lookup, symmetry_lookup, representative_lookup + + +def make_flip_ud_slice_symmetry_tables() -> tuple[ + array.array, array.array, array.array +]: + return _make_symmetry_tables( + "flip_ud_slice", FLIP_MAX * UD_SLICE_MAX, FLIP_UD_SLICE_CLASSES, edge=True + ) + + +def make_corner_symmetry_tables() -> tuple[array.array, array.array, array.array]: + return _make_symmetry_tables("corner", CORNER_MAX, CORNER_CLASSES, edge=False) diff --git a/src/twophase/tables/symmetries.py b/src/twophase/tables/symmetries.py new file mode 100644 index 0000000..d26f645 --- /dev/null +++ b/src/twophase/tables/symmetries.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from typing import overload + +from twophase.pieces import Corner, Edge +from twophase.tables.cubiecube import CubieCube + +# 120 degree clockwise rotation around the long diagonal URF-DBL +ROT_URF_120 = CubieCube( + cp=( + Corner.URF, + Corner.DFR, + Corner.DLF, + Corner.UFL, + Corner.UBR, + Corner.DRB, + Corner.DBL, + Corner.ULB, + ), + co=(1, 2, 1, 2, 2, 1, 2, 1), + ep=( + Edge.UF, + Edge.FR, + Edge.DF, + Edge.FL, + Edge.UB, + Edge.BR, + Edge.DB, + Edge.BL, + Edge.UR, + Edge.DR, + Edge.DL, + Edge.UL, + ), + eo=(1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1), +) + +# 180° rotation around the axis through the F and B centers +ROT_F_180 = CubieCube( + cp=( + Corner.DLF, + Corner.DFR, + Corner.DRB, + Corner.DBL, + Corner.UFL, + Corner.URF, + Corner.UBR, + Corner.ULB, + ), + co=(0, 0, 0, 0, 0, 0, 0, 0), + ep=( + Edge.DL, + Edge.DF, + Edge.DR, + Edge.DB, + Edge.UL, + Edge.UF, + Edge.UR, + Edge.UB, + Edge.FL, + Edge.FR, + Edge.BR, + Edge.BL, + ), + eo=(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), +) + +# 90° clockwise rotation around the axis through the U and D centers +ROT_U_90 = CubieCube( + cp=( + Corner.UBR, + Corner.URF, + Corner.UFL, + Corner.ULB, + Corner.DRB, + Corner.DFR, + Corner.DLF, + Corner.DBL, + ), + co=(0, 0, 0, 0, 0, 0, 0, 0), + ep=( + Edge.UB, + Edge.UR, + Edge.UF, + Edge.UL, + Edge.DB, + Edge.DR, + Edge.DF, + Edge.DL, + Edge.BR, + Edge.FR, + Edge.FL, + Edge.BL, + ), + eo=(0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1), +) + +# reflection in M slice (the plane through the U, D, F, B centers) +REF_M = CubieCube( + cp=( + Corner.UFL, + Corner.URF, + Corner.UBR, + Corner.ULB, + Corner.DLF, + Corner.DFR, + Corner.DRB, + Corner.DBL, + ), + co=(3, 3, 3, 3, 3, 3, 3, 3), + ep=( + Edge.UL, + Edge.UF, + Edge.UR, + Edge.UB, + Edge.DL, + Edge.DF, + Edge.DR, + Edge.DB, + Edge.FL, + Edge.FR, + Edge.BR, + Edge.BL, + ), + eo=(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0), +) + + +class Symmetries: + _symmetries: list[CubieCube] = [] + _inverse_symmetries: list[CubieCube] = [] + + def __new__(cls, subgroup=False): + if not cls._symmetries: + # populate list of symmetries + cube = CubieCube() + for _urf_rot in range(3): + for _f_rot in range(2): + for _u_rot in range(4): + for _m_ref in range(2): + cls._symmetries.append(cube) + cube *= REF_M + cube *= ROT_U_90 + cube *= ROT_F_180 + cube *= ROT_URF_120 + + cls._inverse_symmetries = [] + for sym in cls._symmetries: + for inv_candidate in cls._symmetries: + result = sym.corner_multiply(inv_candidate) + if ( + result.cp[Corner.URF] == Corner.URF + and result.cp[Corner.UFL] == Corner.UFL + and result.cp[Corner.ULB] == Corner.ULB + ): + # location of these three corners is enough to determine inverse + cls._inverse_symmetries.append(inv_candidate) + break + + return super().__new__(cls) + + def __init__(self, subgroup=False): + self._lim = 16 if subgroup else 48 + + @overload + def __getitem__(self, idx: int) -> CubieCube: ... + + @overload + def __getitem__(self, idx: slice) -> list[CubieCube]: ... + + def __getitem__(self, idx: int | slice) -> CubieCube | list[CubieCube]: + return self._symmetries[: self._lim][idx] + + def __iter__(self): + return iter(self._symmetries[: self._lim]) + + def __len__(self) -> int: + return self._lim + + @property + def inverse(self) -> list[CubieCube]: + return self._inverse_symmetries[: self._lim] + + +def cube_symmetries(cube: CubieCube) -> list[int]: + symmetries = Symmetries() + n_sym = len(symmetries) + s = [] + for i, sym in enumerate(symmetries): + cube2 = sym * cube * symmetries.inverse[i] + if cube == cube2: + s.append(i) + cube2 = cube2.invert() + if cube == cube2: + s.append(i + n_sym) + return s diff --git a/src/twophase/tables/tables.py b/src/twophase/tables/tables.py new file mode 100644 index 0000000..0d416db --- /dev/null +++ b/src/twophase/tables/tables.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import array +from pathlib import Path +from typing import Any, Callable + +from twophase import constants +from twophase.tables import generators +from twophase.tables._array import BitpackedArray, dtypes, max_val_to_dtype +from twophase.tables._cache import resolve_cache_path + +# hold tables associated with particular paths in memory. this is preferable to +# functools.cache because we can invalidate single entries without invalidating the +# entire cache +_TABLE_CACHE: dict[Path, Tables] = {} + + +def make_or_load_tables(cache_path: str | Path | None = None) -> Tables: + """ + Load all move tables saved in the location specified by cache_path. If tables have + already been loaded they will be retrieved from an in-memory cache. + + Parameters + ---------- + cache_path : pathlike, optional + The directory in which the move tables are stored. + """ + cache_path = resolve_cache_path(cache_path) + if cache_path not in _TABLE_CACHE: + _TABLE_CACHE[cache_path] = Tables(cache_path) + return _TABLE_CACHE[cache_path] + + +def delete_tables(cache_path: str | Path | None = None) -> None: + """ + Delete all of the move tables stored in the directory specified by cache_path. + + Parameters + ---------- + cache_path : pathlike, optional + The directory in which the move tables to be deleted are stored. + """ + cache_path = resolve_cache_path(cache_path) + if cache_path in _TABLE_CACHE: + del _TABLE_CACHE[cache_path] + for table_path in cache_path.glob("*.bin"): + table_path.unlink() + + +class Tables: + # table attributes are created dynamically, so we need to add type hints manually + # to keep mypy happy + twist_move: array.array + flip_move: array.array + ud_slice_sorted_move: array.array + u_edges_sorted_move: array.array + d_edges_sorted_move: array.array + edge8_move: array.array + corner_move: array.array + + move_conj: array.array + twist_conj: array.array + edge8_conj: array.array + + corner_class_lookup: array.array + corner_symmetry_lookup: array.array + corner_representative_lookup: array.array + flip_ud_slice_class_lookup: array.array + flip_ud_slice_symmetry_lookup: array.array + flip_ud_slice_representative_lookup: array.array + + flip_ud_slice_class_twist_prune: BitpackedArray + corner_class_edge8_prune: BitpackedArray + cornslice_prune: array.array + + edge_merge: array.array + depth_lookup: array.array + + _move_tables: tuple[tuple[str, int, Callable[[], array.array]], ...] = ( + ("twist", constants.TWIST_MAX, generators.make_twist_move_table), + ("flip", constants.FLIP_MAX, generators.make_flip_move_table), + ( + "ud_slice_sorted", + constants.UD_SLICE_MAX * constants.EDGE4_MAX, + generators.make_ud_slice_sorted_move_table, + ), + ( + "u_edges_sorted", + constants.UD_SLICE_MAX * constants.EDGE4_MAX, + generators.make_u_edges_sorted_move_table, + ), + ( + "d_edges_sorted", + constants.UD_SLICE_MAX * constants.EDGE4_MAX, + generators.make_d_edges_sorted_move_table, + ), + ("edge8", constants.EDGE8_MAX, generators.make_edge8_move_table), + ("corner", constants.CORNER_MAX, generators.make_corner_move_table), + ) + _conj_tables: tuple[tuple[str, int, Callable[[], array.array]], ...] = ( + ("twist", constants.TWIST_MAX, generators.make_twist_conj_table), + ("edge8", constants.EDGE8_MAX, generators.make_edge8_conj_table), + ) + _sym_tables: tuple[ + tuple[ + str, int, int, Callable[[], tuple[array.array, array.array, array.array]] + ], + ..., + ] = ( + ( + "corner", + constants.CORNER_CLASSES, + constants.CORNER_MAX, + generators.make_corner_symmetry_tables, + ), + ( + "flip_ud_slice", + constants.FLIP_UD_SLICE_CLASSES, + constants.FLIP_MAX * constants.UD_SLICE_MAX, + generators.make_flip_ud_slice_symmetry_tables, + ), + ) + + def __init__(self, cache_path: Path) -> None: + self.cache_path = cache_path + self.load_tables() + + def _make_or_load_table( + self, table_name: str, dtype: dtypes, constructor: Callable[[], array.array] + ) -> None: + table_path = self.cache_path / f"{table_name}.bin" + if table_path.exists(): + table = array.array(dtype) + table.frombytes(table_path.read_bytes()) + else: + table = constructor() + table_path.write_bytes(table.tobytes()) + setattr(self, table_name, table) + + def _make_or_load_pruning_table( + self, + table_name: str, + constructor: Callable[..., BitpackedArray], + constructor_kwargs: dict[str, Any], + ) -> None: + table_path = self.cache_path / f"{table_name}.bin" + if table_path.exists(): + table = BitpackedArray.frombytes(table_path.read_bytes()) + else: + table = constructor(**constructor_kwargs) + table_path.write_bytes(table.tobytes()) + setattr(self, table_name, table) + + def load_tables(self) -> None: + for coord, max_val, constructor in self._move_tables: + self._make_or_load_table( + table_name=f"{coord}_move", + dtype=max_val_to_dtype(max_val), + constructor=constructor, + ) + + self._make_or_load_table( + table_name="move_conj", + dtype=dtypes.u8, + constructor=generators.make_move_conj_table, + ) + + for coord, max_val, constructor in self._conj_tables: + self._make_or_load_table( + table_name=f"{coord}_conj", + dtype=max_val_to_dtype(max_val), + constructor=getattr(generators, f"make_{coord}_conj_table"), + ) + + for coord, num_classes, max_val, constructor in self._sym_tables: + table_names = ( + f"{coord}_class_lookup", + f"{coord}_symmetry_lookup", + f"{coord}_representative_lookup", + ) + if all( + (self.cache_path / f"{table_name}.bin").exists() + for table_name in table_names + ): + dtypes_ = ( + max_val_to_dtype(num_classes), + dtypes.u8, + max_val_to_dtype(max_val), + ) + tables = [] + for table_name, dtype in zip(table_names, dtypes_): + table = array.array(dtype) + table.frombytes( + (self.cache_path / f"{table_name}.bin").read_bytes() + ) + tables.append(table) + else: + tables = constructor() + for table_name, table in zip(table_names, tables): + (self.cache_path / f"{table_name}.bin").write_bytes(table.tobytes()) + + for table_name, table in zip(table_names, tables): + setattr(self, table_name, table) + + constructor_kwargs = { + name: getattr(self, name) + for name in ( + "twist_move", + "flip_move", + "ud_slice_sorted_move", + "flip_ud_slice_class_lookup", + "flip_ud_slice_symmetry_lookup", + "flip_ud_slice_representative_lookup", + "twist_conj", + ) + } + self._make_or_load_pruning_table( + table_name="flip_ud_slice_class_twist_prune", + constructor=generators.make_flip_ud_slice_class_twist_prune_table, + constructor_kwargs=constructor_kwargs, + ) + + constructor_kwargs = { + name: getattr(self, name) + for name in ( + "corner_move", + "edge8_move", + "corner_class_lookup", + "corner_symmetry_lookup", + "corner_representative_lookup", + "edge8_conj", + ) + } + self._make_or_load_pruning_table( + table_name="corner_class_edge8_prune", + constructor=generators.make_corner_class_edge8_prune_table, + constructor_kwargs=constructor_kwargs, + ) + + constructor_kwargs = { + name: getattr(self, name) + for name in ("corner_move", "ud_slice_sorted_move") + } + self._make_or_load_table( + table_name="cornslice_prune", + dtype=dtypes.u16, + constructor=lambda: generators.make_cornslice_prune_table( + **constructor_kwargs + ), + ) + + self._make_or_load_table( + table_name="edge_merge", + dtype=dtypes.u16, + constructor=generators.make_edge_merge_table, + ) + self._make_or_load_table( + table_name="depth_lookup", + dtype=dtypes.u8, + constructor=generators.make_depth_lookup_table, + ) From 00f5dda55bd65fd65e8273e9f59dfe25080fbef2 Mon Sep 17 00:00:00 2001 From: tcbegley Date: Mon, 31 Aug 2026 08:16:01 +0100 Subject: [PATCH 3/6] Implement symmetry-aware two-phase solver --- src/twophase/solve/__init__.py | 0 src/twophase/solve/solver.py | 611 +++++++++++++++++++++++++++++++++ src/twophase/solve/state.py | 25 ++ 3 files changed, 636 insertions(+) create mode 100644 src/twophase/solve/__init__.py create mode 100644 src/twophase/solve/solver.py create mode 100644 src/twophase/solve/state.py diff --git a/src/twophase/solve/__init__.py b/src/twophase/solve/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/twophase/solve/solver.py b/src/twophase/solve/solver.py new file mode 100644 index 0000000..f3252f5 --- /dev/null +++ b/src/twophase/solve/solver.py @@ -0,0 +1,611 @@ +""" +Two-phase Rubik's cube solver using Kociemba's algorithm. + +The implementation separates three concerns: + +- :class:`Solver` is a thread-safe facade over the read-only lookup tables. +- :class:`_CoordinateModel` contains pure coordinate transitions and heuristics. +- :class:`_SearchPortfolio` coordinates symmetry-equivalent searches. +- :class:`_SearchSession` owns the mutable state for one search tree. + +The search uses IDA* in two phases. Phase 1 reduces the cube to the G1 subgroup; +phase 2 solves it using only moves that preserve G1. Progressive searches continue +after finding a solution, using its length as the bound for subsequent searches. +""" + +import time +from collections.abc import Generator +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from queue import Queue +from threading import Event + +from twophase.constants import ( + EDGE4_MAX, + EDGE8_MAX, + FLIP_MAX, + MOVES, + PHASE_1_MOVES, + PHASE_2_MOVES, + PHASE_2_PRUNE_UNFILLED_DEPTH, + SYMMETRIES_D4H, + TWIST_MAX, +) +from twophase.pieces import Move +from twophase.solve.state import Phase1State, Phase2State +from twophase.tables.cubiecube import CubieCube +from twophase.tables.symmetries import Symmetries +from twophase.tables.tables import Tables, make_or_load_tables + +# Phase 2 searches at most this many moves deep. Optimal phase 2 solutions rarely +# exceed 10 moves. +MAX_PHASE2_DEPTH = 11 + +# When phase 1 is already in G1 and nearly out of budget, phase 2 moves are deferred +# to the dedicated phase 2 search. +_PHASE_1_DEFERRED_MOVES = tuple( + move for move in PHASE_1_MOVES if move not in PHASE_2_MOVES +) + +# Calling time.monotonic() at every node is expensive. +_TIMEOUT_CHECK_INTERVAL = 10_000 + +# Conjugating by these symmetries views the cube with each of the U, R, and F +# directions as the phase 1 U/D axis. Searching their inverses gives six distinct +# orderings of the same solution space. +_SEARCH_SYMMETRIES = (0, 16, 32) + + +def _make_successor_table(moves: tuple[int, ...]) -> tuple[tuple[int, ...], ...]: + """Precompute non-redundant moves for each possible previous move.""" + return tuple( + tuple(move for move in moves if move // 3 - previous_move // 3 not in (0, 3)) + for previous_move in range(MOVES) + ) + + +_PHASE_1_ROOT_MOVES = tuple(PHASE_1_MOVES) +_PHASE_2_ROOT_MOVES = tuple(PHASE_2_MOVES) +_PHASE_1_SUCCESSORS = _make_successor_table(_PHASE_1_ROOT_MOVES) +_PHASE_1_DEFERRED_SUCCESSORS = _make_successor_table(_PHASE_1_DEFERRED_MOVES) +_PHASE_2_SUCCESSORS = _make_successor_table(_PHASE_2_ROOT_MOVES) + + +@dataclass(frozen=True, slots=True) +class _SearchVariant: + cube: CubieCube + symmetry_index: int + inverted: bool + + +@dataclass(frozen=True, slots=True) +class _WorkerDone: + error: BaseException | None = None + + +class Solver: + """ + Kociemba two-phase solver with progressive solution shortening. + + A solver owns only the read-only coordinate model that is shared by its searches. + Each call creates a search session for its mutable path and control state, so one + solver may safely service concurrent calls. The search is CPU-bound, however, so + parallel speedups may require processes or a free-threaded Python runtime. + """ + + def __init__(self, cache_path: str | Path | None = None) -> None: + tables = make_or_load_tables(cache_path) + self._coordinates = _CoordinateModel(tables) + + def solve( + self, cube: CubieCube, max_length: int = 20, timeout: float = float("inf") + ) -> list[Move] | None: + """ + Find a solution and return it immediately. + + ``max_length`` is an inclusive upper bound. ``None`` is returned if no + solution is found within that bound before the timeout. + """ + return _SearchPortfolio( + self._coordinates, cube, max_length, timeout + ).first_solution() + + def solve_progressively( + self, cube: CubieCube, max_length: int = 20, timeout: float = 10.0 + ) -> Generator[list[Move]]: + """ + Yield shorter solutions until the search is exhausted or times out. + + Independent workers search rotated and inverted views of the cube. Results are + transformed back to the original orientation and merged here so that yielded + lengths remain strictly decreasing. + """ + yield from _SearchPortfolio( + self._coordinates, cube, max_length, timeout + ).progressive_solutions() + + +class _CoordinateModel: + """ + Read-only coordinate transitions and pruning heuristics. + + Methods on this class are deterministic functions of their arguments and the + injected lookup tables. Keeping resource loading outside the model makes that + dependency explicit, while keeping coordinate details out of the search control + flow. + """ + + def __init__(self, tables: Tables) -> None: + self.tables = tables + self.symmetries = Symmetries() + self.inverse_symmetry_indices = tuple( + next( + i for i, candidate in enumerate(self.symmetries) if candidate == inverse + ) + for inverse in self.symmetries.inverse + ) + + def search_variants(self, cube: CubieCube) -> list[_SearchVariant]: + """Return distinct rotated views of the cube and its inverse.""" + variants: list[_SearchVariant] = [] + for inverted in (False, True): + source = cube.invert() if inverted else cube + for symmetry_index in _SEARCH_SYMMETRIES: + transformed = ( + self.symmetries[symmetry_index] + * source + * self.symmetries.inverse[symmetry_index] + ) + if any(variant.cube == transformed for variant in variants): + continue + variants.append(_SearchVariant(transformed, symmetry_index, inverted)) + return variants + + def restore_solution( + self, solution: list[Move], variant: _SearchVariant + ) -> list[Move]: + """Map a variant's solution back to the original cube orientation.""" + inverse_symmetry = self.inverse_symmetry_indices[variant.symmetry_index] + restored = [ + Move(self.tables.move_conj[MOVES * inverse_symmetry + move]) + for move in solution + ] + if variant.inverted: + restored = [ + Move((move // 3) * 3 + (2 - move % 3)) for move in reversed(restored) + ] + return restored + + @staticmethod + def phase1_from_cube(cube: CubieCube) -> Phase1State: + return Phase1State( + cube.twist, + cube.flip, + cube.ud_slice_sorted, + cube.u_edges_sorted, + cube.d_edges_sorted, + cube.corner, + ) + + def apply_phase1_move(self, state: Phase1State, move: int) -> Phase1State: + """Apply a move to every phase 1 coordinate.""" + tables = self.tables + return Phase1State( + tables.twist_move[MOVES * state.twist + move], + tables.flip_move[MOVES * state.flip + move], + tables.ud_slice_sorted_move[MOVES * state.ud_slice_sorted + move], + tables.u_edges_sorted_move[MOVES * state.u_edges_sorted + move], + tables.d_edges_sorted_move[MOVES * state.d_edges_sorted + move], + tables.corner_move[MOVES * state.corner + move], + ) + + def phase1_distance_after_move( + self, state: Phase1State, move: int, previous_distance: int + ) -> int: + """Compute the pruning distance without allocating a successor state.""" + tables = self.tables + twist = tables.twist_move[MOVES * state.twist + move] + flip = tables.flip_move[MOVES * state.flip + move] + ud_slice_sorted = tables.ud_slice_sorted_move[ + MOVES * state.ud_slice_sorted + move + ] + + # Inline the depth-mod-3 lookup here because this runs for every candidate + # move. The general helper remains useful for the much less frequent pruning + # gradient walk. + flip_ud_slice = FLIP_MAX * (ud_slice_sorted // EDGE4_MAX) + flip + symmetry_class = tables.flip_ud_slice_class_lookup[flip_ud_slice] + symmetry = tables.flip_ud_slice_symmetry_lookup[flip_ud_slice] + prune_index = ( + TWIST_MAX * symmetry_class + + tables.twist_conj[SYMMETRIES_D4H * twist + symmetry] + ) + depth_mod3 = tables.flip_ud_slice_class_twist_prune[prune_index] + return tables.depth_lookup[3 * previous_distance + depth_mod3] + + def phase2_from_phase1(self, state: Phase1State) -> Phase2State: + tables = self.tables + edge8 = tables.edge_merge[ + EDGE4_MAX * state.u_edges_sorted + state.d_edges_sorted % EDGE4_MAX + ] + return Phase2State(state.ud_slice_sorted, edge8, state.corner) + + def apply_phase2_move(self, state: Phase2State, move: int) -> Phase2State: + tables = self.tables + corner = tables.corner_move[MOVES * state.corner + move] + return Phase2State( + tables.ud_slice_sorted_move[MOVES * state.edge4 + move], + tables.edge8_move[MOVES * state.edge8 + move], + corner, + ) + + def _phase1_depth_mod3(self, twist: int, flip: int, ud_slice_sorted: int) -> int: + tables = self.tables + flip_ud_slice = FLIP_MAX * (ud_slice_sorted // EDGE4_MAX) + flip + symmetry_class = tables.flip_ud_slice_class_lookup[flip_ud_slice] + symmetry = tables.flip_ud_slice_symmetry_lookup[flip_ud_slice] + return tables.flip_ud_slice_class_twist_prune[ + TWIST_MAX * symmetry_class + + tables.twist_conj[SYMMETRIES_D4H * twist + symmetry] + ] + + def phase1_depth_mod3(self, state: Phase1State) -> int: + return self._phase1_depth_mod3(state.twist, state.flip, state.ud_slice_sorted) + + def _phase2_depth_mod3(self, edge8: int, corner: int) -> int: + tables = self.tables + symmetry_class = tables.corner_class_lookup[corner] + symmetry = tables.corner_symmetry_lookup[corner] + return tables.corner_class_edge8_prune[ + EDGE8_MAX * symmetry_class + + tables.edge8_conj[SYMMETRIES_D4H * edge8 + symmetry] + ] + + def phase2_depth_mod3(self, state: Phase2State) -> int: + return self._phase2_depth_mod3(state.edge8, state.corner) + + def phase1_distance(self, state: Phase1State) -> int: + """Recover the exact G1 distance from a depth-mod-3 pruning table.""" + depth_mod3 = self.phase1_depth_mod3(state) + depth = 0 + + while not self.phase1_complete(state): + if depth_mod3 == 0: + depth_mod3 = 3 + for move in PHASE_1_MOVES: + next_state = self.apply_phase1_move(state, move) + if self.phase1_depth_mod3(next_state) == depth_mod3 - 1: + state = next_state + depth += 1 + depth_mod3 -= 1 + break + else: + raise RuntimeError("phase 1 pruning gradient is broken") + return depth + + def phase2_distance(self, state: Phase2State) -> int: + """Recover the exact corner × edge8 distance used as a lower bound.""" + depth_mod3 = self.phase2_depth_mod3(state) + if depth_mod3 == 3: + # The pruning table was intentionally not filled to this depth. This lower + # bound exceeds the maximum phase 2 search depth. + return PHASE_2_PRUNE_UNFILLED_DEPTH + + depth = 0 + # This table covers corner × edge8 only; edge4 is covered by the independent + # cornslice heuristic used by the search. Keep these as scalar values because + # this gradient walk is run for every phase 1 leaf. + edge8, corner = state.edge8, state.corner + tables = self.tables + while edge8 != 0 or corner != 0: + if depth_mod3 == 0: + depth_mod3 = 3 + for move in PHASE_2_MOVES: + next_corner = tables.corner_move[MOVES * corner + move] + next_edge8 = tables.edge8_move[MOVES * edge8 + move] + if self._phase2_depth_mod3(next_edge8, next_corner) == depth_mod3 - 1: + edge8, corner = next_edge8, next_corner + depth += 1 + depth_mod3 -= 1 + break + else: + raise RuntimeError("phase 2 pruning gradient is broken") + return depth + + def phase2_distance_after_move( + self, previous_distance: int, state: Phase2State + ) -> int: + """Derive a neighbour's distance, falling back for unfilled entries.""" + depth_mod3 = self.phase2_depth_mod3(state) + if depth_mod3 == 3: + # Be optimistic and let the independent cornslice table do the pruning. + return 0 + return self.tables.depth_lookup[3 * previous_distance + depth_mod3] + + def cornslice_distance(self, state: Phase2State) -> int: + return self.tables.cornslice_prune[EDGE4_MAX * state.corner + state.edge4] + + @staticmethod + def phase1_complete(state: Phase1State) -> bool: + return ( + state.twist == 0 + and state.flip == 0 + and state.ud_slice_sorted // EDGE4_MAX == 0 + ) + + @staticmethod + def phase2_complete(state: Phase2State) -> bool: + return state.edge4 == 0 and state.edge8 == 0 and state.corner == 0 + + +class _SearchSession: + """ + Mutable control state for one search invocation. + + Cube states remain immutable and are passed down the recursion. Only the current + move path, incumbent bound, timeout bookkeeping, and newly found solutions mutate. + Keeping this state per invocation makes the reusable solver safe for concurrent + calls. + """ + + def __init__( + self, + coordinates: _CoordinateModel, + cube: CubieCube, + max_length: int, + deadline: float, + cancelled: Event, + *, + stop_on_first: bool, + ) -> None: + self.coordinates = coordinates + self.root = coordinates.phase1_from_cube(cube) + self.max_length = max_length + self.deadline = deadline + self.cancelled = cancelled + self.stop_on_first = stop_on_first + + self.path: list[int] = [] + self.shortest_length = max_length + 1 + self.node_count = 0 + self.locally_stopped = False + self.new_solutions: list[list[Move]] = [] + + def run(self) -> Generator[list[Move]]: + if self.max_length < 0 or self._deadline_reached(): + return + + phase1_min_distance = self.coordinates.phase1_distance(self.root) + + # max_length is inclusive: phase 1 may consume the entire move budget when + # phase 2 needs zero moves. + for phase1_budget in range(phase1_min_distance, self.max_length + 1): + if phase1_budget >= self.shortest_length: + break + + self.new_solutions.clear() + self._search_phase1(self.root, phase1_min_distance, phase1_budget) + yield from self.new_solutions + + if self._should_stop(): + break + + def _search_phase1(self, state: Phase1State, distance: int, remaining: int) -> None: + if not self._visit_node(): + return + + if remaining == 0: + self._start_phase2(state) + return + + if distance > remaining: + return + + # If phase 1 is already complete, let phase 2 search its own moves unless the + # phase 1 budget is large enough that deferring them could harm completeness. + defer_phase2_moves = distance == 0 and remaining < 5 + + if self.path: + successors = ( + _PHASE_1_DEFERRED_SUCCESSORS[self.path[-1]] + if defer_phase2_moves + else _PHASE_1_SUCCESSORS[self.path[-1]] + ) + else: + successors = ( + _PHASE_1_DEFERRED_MOVES if defer_phase2_moves else _PHASE_1_ROOT_MOVES + ) + + for move in successors: + next_distance = self.coordinates.phase1_distance_after_move( + state, move, distance + ) + if next_distance >= remaining: + continue + + next_state = self.coordinates.apply_phase1_move(state, move) + self.path.append(move) + self._search_phase1(next_state, next_distance, remaining - 1) + self.path.pop() + + if self._should_stop(): + return + + def _start_phase2(self, phase1_state: Phase1State) -> None: + if self._deadline_reached(): + return + + state = self.coordinates.phase2_from_phase1(phase1_state) + budget_limit = min(self.shortest_length - len(self.path), MAX_PHASE2_DEPTH) + + # Two independent heuristics cheaply reject unpromising G1 states. + if self.coordinates.cornslice_distance(state) >= budget_limit: + return + min_distance = self.coordinates.phase2_distance(state) + if min_distance >= budget_limit: + return + + for budget in range(min_distance, budget_limit): + if self._search_phase2(state, min_distance, budget): + return + if self._should_stop(): + return + + def _search_phase2(self, state: Phase2State, distance: int, remaining: int) -> bool: + if not self._visit_node(): + return False + + if self.coordinates.phase2_complete(state): + self._record_solution() + return True + + if distance > remaining: + return False + + successors = ( + _PHASE_2_SUCCESSORS[self.path[-1]] if self.path else _PHASE_2_ROOT_MOVES + ) + for move in successors: + next_state = self.coordinates.apply_phase2_move(state, move) + next_distance = self.coordinates.phase2_distance_after_move( + distance, next_state + ) + cornslice_distance = self.coordinates.cornslice_distance(next_state) + if max(next_distance, cornslice_distance) >= remaining: + continue + + self.path.append(move) + solved = self._search_phase2(next_state, next_distance, remaining - 1) + self.path.pop() + + if solved or self._should_stop(): + return solved + + return False + + def _record_solution(self) -> None: + length = len(self.path) + if length >= self.shortest_length: + return + + self.shortest_length = length + self.new_solutions.append([Move(move) for move in self.path]) + if self.stop_on_first: + self.locally_stopped = True + self.cancelled.set() + + def _should_stop(self) -> bool: + return self.locally_stopped or self.cancelled.is_set() + + def _visit_node(self) -> bool: + if self._should_stop(): + return False + + self.node_count += 1 + if self.node_count % _TIMEOUT_CHECK_INTERVAL == 0: + return not self._deadline_reached() + return True + + def _deadline_reached(self) -> bool: + if time.monotonic() <= self.deadline: + return False + self.locally_stopped = True + self.cancelled.set() + return True + + +class _SearchPortfolio: + """ + Coordinate independent searches of symmetry-equivalent cube states. + + Workers share a deadline and cancellation signal but no mutable DFS state. This + makes each search tree independent while allowing the first successful ordering to + stop the rest. + """ + + def __init__( + self, + coordinates: _CoordinateModel, + cube: CubieCube, + max_length: int, + timeout: float, + ) -> None: + self.coordinates = coordinates + self.variants = coordinates.search_variants(cube) + self.max_length = max_length + self.deadline = time.monotonic() + timeout + self.cancelled = Event() + + def _session( + self, variant: _SearchVariant, *, stop_on_first: bool + ) -> _SearchSession: + return _SearchSession( + self.coordinates, + variant.cube, + self.max_length, + self.deadline, + self.cancelled, + stop_on_first=stop_on_first, + ) + + def _first_solution_for(self, variant: _SearchVariant) -> list[Move] | None: + solution = next(self._session(variant, stop_on_first=True).run(), None) + if solution is None: + return None + return self.coordinates.restore_solution(solution, variant) + + def first_solution(self) -> list[Move] | None: + with ThreadPoolExecutor(max_workers=len(self.variants)) as executor: + futures = [ + executor.submit(self._first_solution_for, variant) + for variant in self.variants + ] + try: + for future in as_completed(futures): + solution = future.result() + if solution is not None: + return solution + finally: + self.cancelled.set() + return None + + def _collect_solutions( + self, variant: _SearchVariant, results: Queue[list[Move] | _WorkerDone] + ) -> None: + try: + for solution in self._session(variant, stop_on_first=False).run(): + results.put(self.coordinates.restore_solution(solution, variant)) + except BaseException as error: + results.put(_WorkerDone(error)) + else: + results.put(_WorkerDone()) + + def progressive_solutions(self) -> Generator[list[Move]]: + results: Queue[list[Move] | _WorkerDone] = Queue() + executor = ThreadPoolExecutor(max_workers=len(self.variants)) + futures = [ + executor.submit(self._collect_solutions, variant, results) + for variant in self.variants + ] + completed = 0 + shortest_length = self.max_length + 1 + + try: + while completed < len(self.variants): + result = results.get() + if isinstance(result, _WorkerDone): + completed += 1 + if result.error is not None: + raise result.error + elif len(result) < shortest_length: + shortest_length = len(result) + yield result + finally: + self.cancelled.set() + executor.shutdown(wait=True, cancel_futures=True) + # Surface an unexpected executor failure that happened before the worker + # could put its completion marker on the queue. + for future in futures: + if future.done() and not future.cancelled(): + future.result() diff --git a/src/twophase/solve/state.py b/src/twophase/solve/state.py new file mode 100644 index 0000000..5ace06f --- /dev/null +++ b/src/twophase/solve/state.py @@ -0,0 +1,25 @@ +"""Immutable coordinate states used by the two search phases.""" + +from typing import NamedTuple + + +class Phase1State(NamedTuple): + """Coordinates needed to search phase 1 and transition into phase 2.""" + + # Coordinates that define phase 1 membership. + twist: int + flip: int + ud_slice_sorted: int + + # Coordinates carried along so phase 2 can start without replaying moves. + u_edges_sorted: int + d_edges_sorted: int + corner: int + + +class Phase2State(NamedTuple): + """Coordinates needed to search phase 2.""" + + edge4: int + edge8: int + corner: int From 714a47fb4e63b7fe7b2b91eb9d8421644af58600 Mon Sep 17 00:00:00 2001 From: tcbegley Date: Mon, 31 Aug 2026 08:17:11 +0100 Subject: [PATCH 4/6] Expose facelet parsing and formatted solving helpers --- README.md | 28 ++++- src/twophase/__init__.py | 21 +++- src/twophase/cubestring.py | 206 +++++++++++++++++++++++++++++++++++++ src/twophase/interface.py | 39 +++++++ 4 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 src/twophase/cubestring.py create mode 100644 src/twophase/interface.py diff --git a/README.md b/README.md index 845ae4d..bcedd01 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,32 @@ states with compact coordinates, and uses generated move and pruning tables to search for solutions. The implementation favors readable, well-tested Python alongside the optimizations needed for a practical solver. +## Usage + +Pass a 54-character facelet string in `U R F D L B` face order to `solve`: + +```python +from twophase import solve + +cube = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" +assert solve(cube) == "" +``` + +`solve` returns a space-separated sequence in standard cube notation, or +`None` when a solution cannot be found before the requested length or time +limit. For lower-level use, `parse` converts a facelet string to a `CubieCube`, +`Solver` returns `Move` values, and `format_moves` renders those values. + +```python +from twophase import Solver, format_moves, parse + +moves = Solver().solve(parse(cube)) +print(format_moves(moves or [])) +``` + +The first solve generates lookup tables. Pass `cache_path` to `Solver` or the +high-level helpers to choose where they are stored. + ## Development This repository uses [uv](https://docs.astral.sh/uv/) to manage its Python @@ -17,5 +43,3 @@ environment and lock file: uv sync --all-groups uv run pytest ``` - -The public parsing and solving interface is introduced with the solver. diff --git a/src/twophase/__init__.py b/src/twophase/__init__.py index f1d8f0b..a298efd 100644 --- a/src/twophase/__init__.py +++ b/src/twophase/__init__.py @@ -2,9 +2,28 @@ from importlib.metadata import PackageNotFoundError, version +from twophase.cubestring import parse +from twophase.interface import solve, solve_progressively +from twophase.pieces import Color, Corner, Edge, Facelet, Move, format_moves +from twophase.solve.solver import Solver +from twophase.tables.cubiecube import CubieCube + try: __version__ = version("twophase") except PackageNotFoundError: __version__ = "0.2.0+fallback" -__all__ = ["__version__"] +__all__ = [ + "Color", + "Corner", + "CubieCube", + "Edge", + "Facelet", + "Move", + "Solver", + "__version__", + "format_moves", + "parse", + "solve", + "solve_progressively", +] diff --git a/src/twophase/cubestring.py b/src/twophase/cubestring.py new file mode 100644 index 0000000..13c2957 --- /dev/null +++ b/src/twophase/cubestring.py @@ -0,0 +1,206 @@ +""" +Parse a cube definition string into a CubieCube. + +A cube string is a 54-character string specifying the sticker colour on each +facelet, reading each face (U R F D L B) row by row from top-left to +bottom-right:: + + Face layout: Facelet numbering: + + U U U 1 2 3 + U U U 4 5 6 + U U U 7 8 9 + L L L F F F R R R B B B + L L L F F F R R R B B B + L L L F F F R R R B B B + D D D + D D D + D D D + +Valid characters are the face letters ``U R F D L B`` (case-insensitive). + +Example +------- +The solved cube: + +>>> from twophase.cubestring import parse +>>> cc = parse("UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB") +""" + +from twophase.exceptions import InvalidCube +from twophase.pieces import Color, Corner, Edge, Facelet +from twophase.tables.cubiecube import CubieCube, verify + +_CORNER_FACELETS: tuple[tuple[Facelet, Facelet, Facelet], ...] = ( + (Facelet.U9, Facelet.R1, Facelet.F3), # URF + (Facelet.U7, Facelet.F1, Facelet.L3), # UFL + (Facelet.U1, Facelet.L1, Facelet.B3), # ULB + (Facelet.U3, Facelet.B1, Facelet.R3), # UBR + (Facelet.D3, Facelet.F9, Facelet.R7), # DFR + (Facelet.D1, Facelet.L9, Facelet.F7), # DLF + (Facelet.D7, Facelet.B9, Facelet.L7), # DBL + (Facelet.D9, Facelet.R9, Facelet.B7), # DRB +) + +_EDGE_FACELETS: tuple[tuple[Facelet, Facelet], ...] = ( + (Facelet.U6, Facelet.R2), # UR + (Facelet.U8, Facelet.F2), # UF + (Facelet.U4, Facelet.L2), # UL + (Facelet.U2, Facelet.B2), # UB + (Facelet.D6, Facelet.R8), # DR + (Facelet.D2, Facelet.F8), # DF + (Facelet.D4, Facelet.L8), # DL + (Facelet.D8, Facelet.B8), # DB + (Facelet.F6, Facelet.R4), # FR + (Facelet.F4, Facelet.L6), # FL + (Facelet.B6, Facelet.L4), # BL + (Facelet.B4, Facelet.R6), # BR +) + +# The colours that appear on each piece in the solved state. Corner colours +# are ordered (U/D face, clockwise, clockwise) matching _CORNER_FACELETS. +_CORNER_COLORS: tuple[tuple[Color, Color, Color], ...] = ( + (Color.U, Color.R, Color.F), # URF + (Color.U, Color.F, Color.L), # UFL + (Color.U, Color.L, Color.B), # ULB + (Color.U, Color.B, Color.R), # UBR + (Color.D, Color.F, Color.R), # DFR + (Color.D, Color.L, Color.F), # DLF + (Color.D, Color.B, Color.L), # DBL + (Color.D, Color.R, Color.B), # DRB +) + +_EDGE_COLORS: tuple[tuple[Color, Color], ...] = ( + (Color.U, Color.R), # UR + (Color.U, Color.F), # UF + (Color.U, Color.L), # UL + (Color.U, Color.B), # UB + (Color.D, Color.R), # DR + (Color.D, Color.F), # DF + (Color.D, Color.L), # DL + (Color.D, Color.B), # DB + (Color.F, Color.R), # FR + (Color.F, Color.L), # FL + (Color.B, Color.L), # BL + (Color.B, Color.R), # BR +) + + +def parse(cube_string: str) -> CubieCube: + """Parse a 54-character cube string into a ``CubieCube``. + + Parameters + ---------- + cube_string + A 54-character string of face letters (``U R F D L B``, + case-insensitive) describing the sticker on each facelet. + + Returns + ------- + CubieCube + The cube in cubie-level representation. + + Raises + ------ + InvalidCube + If the string is malformed or does not describe a valid cube. + """ + facelets = _parse_facelets(cube_string) + cp, co = _extract_corners(facelets) + ep, eo = _extract_edges(facelets) + + cube = CubieCube(cp=tuple(cp), co=tuple(co), ep=tuple(ep), eo=tuple(eo)) + verify(cube) + return cube + + +def _parse_facelets(cube_string: str) -> list[Color]: + """ + Validate and convert a cube string into a list of 54 Colors. + """ + cube_string = cube_string.upper() + if len(cube_string) != 54: + raise InvalidCube( + f"cube string must be exactly 54 characters, got {len(cube_string)}" + ) + + counts = [0] * 6 + facelets: list[Color] = [] + for char in cube_string: + try: + color = Color[char] + except KeyError: + raise InvalidCube(f"invalid character {char!r} in cube string") from None + counts[color] += 1 + facelets.append(color) + + for color in Color: + if color == Color.NULL: + continue + if counts[color] != 9: + raise InvalidCube( + f"each colour must appear exactly 9 times, " + f"but {color.name} appears {counts[color]} times" + ) + + return facelets + + +def _extract_corners(facelets: list[Color]) -> tuple[list[Corner], list[int]]: + """ + Determine corner permutation and orientation from facelets. + """ + cp: list[Corner] = [Corner.NULL] * 8 + co: list[int] = [0] * 8 + + for i in range(8): + f0, f1, f2 = _CORNER_FACELETS[i] + + # find which of the three facelets carries the U or D colour — that + # tells us the orientation (0 = no twist, 1 = clockwise, 2 = counter) + for orientation in range(3): + if facelets[(f0, f1, f2)[orientation]] in (Color.U, Color.D): + break + else: + raise InvalidCube(f"corner at position {i} has no U/D facelet") + + # read the other two colours in clockwise order from the U/D facelet + color1 = facelets[(f0, f1, f2)[(orientation + 1) % 3]] + color2 = facelets[(f0, f1, f2)[(orientation + 2) % 3]] + + # match against the known corner colour patterns + for j in range(8): + if color1 == _CORNER_COLORS[j][1] and color2 == _CORNER_COLORS[j][2]: + cp[i] = Corner(j) + co[i] = orientation + break + else: + raise InvalidCube(f"corner at position {i} has unrecognised colours") + + return cp, co + + +def _extract_edges(facelets: list[Color]) -> tuple[list[Edge], list[int]]: + """ + Determine edge permutation and orientation from facelets. + """ + ep: list[Edge] = [Edge.NULL] * 12 + eo: list[int] = [0] * 12 + + for i in range(12): + f0, f1 = _EDGE_FACELETS[i] + c0, c1 = facelets[f0], facelets[f1] + + for j in range(12): + if c0 == _EDGE_COLORS[j][0] and c1 == _EDGE_COLORS[j][1]: + ep[i] = Edge(j) + eo[i] = 0 + break + if c0 == _EDGE_COLORS[j][1] and c1 == _EDGE_COLORS[j][0]: + ep[i] = Edge(j) + eo[i] = 1 + break + else: + raise InvalidCube(f"edge at position {i} has unrecognised colours") + + return ep, eo diff --git a/src/twophase/interface.py b/src/twophase/interface.py new file mode 100644 index 0000000..1f7c338 --- /dev/null +++ b/src/twophase/interface.py @@ -0,0 +1,39 @@ +"""Convenient string-based entry points for the two-phase solver.""" + +from collections.abc import Generator +from pathlib import Path + +from twophase.cubestring import parse +from twophase.pieces import format_moves +from twophase.solve.solver import Solver + + +def solve( + cube_string: str, + max_length: int = 20, + timeout: float = 10.0, + cache_path: str | Path | None = None, +) -> str | None: + """Solve a facelet string and return its solution in standard cube notation. + + ``None`` is returned when no solution is found within ``max_length`` before + ``timeout`` expires. Invalid facelet strings raise :class:`InvalidCube`. + """ + cube = parse(cube_string) + moves = Solver(cache_path).solve(cube, max_length, timeout) + return None if moves is None else format_moves(moves) + + +def solve_progressively( + cube_string: str, + max_length: int = 20, + timeout: float = 10.0, + cache_path: str | Path | None = None, +) -> Generator[str]: + """Yield successively shorter solutions for a facelet string.""" + cube = parse(cube_string) + solver = Solver(cache_path) + yield from ( + format_moves(moves) + for moves in solver.solve_progressively(cube, max_length, timeout) + ) From 65d64e4462ea8dbc0afc26578eb34006b1a01731 Mon Sep 17 00:00:00 2001 From: tcbegley Date: Mon, 31 Aug 2026 08:21:11 +0100 Subject: [PATCH 5/6] Add coverage for models, tables, solver, and interface --- tests/conftest.py | 17 ++ tests/tables/test_array.py | 55 ++++++ tests/tables/test_cache.py | 42 +++++ tests/tables/test_cubiecube.py | 329 +++++++++++++++++++++++++++++++++ tests/tables/test_tables.py | 284 ++++++++++++++++++++++++++++ tests/test_cubestring.py | 96 ++++++++++ tests/test_interface.py | 34 ++++ tests/test_solver.py | 137 ++++++++++++++ tests/test_symmetries.py | 65 +++++++ tests/test_version.py | 5 + 10 files changed, 1064 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/tables/test_array.py create mode 100644 tests/tables/test_cache.py create mode 100644 tests/tables/test_cubiecube.py create mode 100644 tests/tables/test_tables.py create mode 100644 tests/test_cubestring.py create mode 100644 tests/test_interface.py create mode 100644 tests/test_solver.py create mode 100644 tests/test_symmetries.py create mode 100644 tests/test_version.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8df1a5b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +import os + +import pytest + +from twophase.tables import delete_tables, make_or_load_tables + + +@pytest.fixture(scope="session") +def tables(tmp_path_factory): + if "REMAKE_TABLES" in os.environ: + # In CI we want to remake tables from scratch, but when testing locally that + # can be annoying since it takes a little while + path = tmp_path_factory.mktemp("tables") / "tables.pkl" + yield make_or_load_tables(path) + delete_tables(path) + else: + yield make_or_load_tables() diff --git a/tests/tables/test_array.py b/tests/tables/test_array.py new file mode 100644 index 0000000..78958f5 --- /dev/null +++ b/tests/tables/test_array.py @@ -0,0 +1,55 @@ +import pytest + +from twophase.tables._array import ( + BitpackedArray, + dtype_to_max_val, + dtypes, + max_val_to_dtype, +) + +DTYPE_MAX_VAL_PAIRS = [ + (dtypes.u8, 255), + (dtypes.u16, 65_535), + (dtypes.u32, 4_294_967_295), +] + +MAX_VAL_DTYPE_TEST_CASES = [ + (0, dtypes.u8), + (254, dtypes.u8), + (255, dtypes.u16), + (1_000, dtypes.u16), + (65_534, dtypes.u16), + (65_535, dtypes.u32), + (1_000_000, dtypes.u32), +] + + +@pytest.mark.parametrize(["dtype", "max_val"], DTYPE_MAX_VAL_PAIRS) +def test_dtype_to_max_val(dtype, max_val): + assert dtype_to_max_val(dtype) == max_val + + +@pytest.mark.parametrize(["max_val", "dtype"], MAX_VAL_DTYPE_TEST_CASES) +def test_max_val_to_dtype(max_val, dtype): + assert max_val_to_dtype(max_val) == dtype + + +def test_bitpacked_array(): + array = BitpackedArray(16) + + # array has required length + assert len(array) == 16 + # underlying array is minimum length required to hold the requested number of + # entries + assert len(array._array) == 1 + + # all bits are initially 1 + assert list(array) == [0b11] * 16 + + # zero all entries but the first + for i in range(1, 16): + array[i] = 0b00 + + assert list(array) == [0b11] + [0b00] * 15 + assert array[0] == 0b11 + assert all(array[i] == 0b00 for i in range(1, 16)) diff --git a/tests/tables/test_cache.py b/tests/tables/test_cache.py new file mode 100644 index 0000000..71b4b08 --- /dev/null +++ b/tests/tables/test_cache.py @@ -0,0 +1,42 @@ +from importlib.resources import files +from pathlib import Path +from tempfile import NamedTemporaryFile, TemporaryDirectory +from typing import cast + +import pytest + +from twophase.tables._cache import resolve_cache_path + + +def test_resolve_default_cache_path(): + resolved_path = resolve_cache_path(None) + assert isinstance(resolved_path, Path) + assert resolved_path.exists() + assert resolved_path.is_dir() + assert resolved_path.is_relative_to( + cast(Path, files("twophase")) / "tables" / ".cache" + ) + + +def resolve_str_cache_path(): + with TemporaryDirectory() as tempdir: + resolved_path = resolve_cache_path(tempdir) + assert isinstance(resolved_path, Path) + assert resolved_path.exists() + assert resolved_path.is_dir() + assert resolved_path.is_relative_to(Path(tempdir)) + + +def resolve_pathlib_cache_path(): + with TemporaryDirectory() as tempdir: + tempdir = Path(tempdir) + resolved_path = resolve_cache_path(tempdir) + assert isinstance(resolved_path, Path) + assert resolved_path.exists() + assert resolved_path.is_dir() + assert resolved_path.is_relative_to(Path(tempdir)) + + +def raises_value_error(): + with pytest.raises(ValueError), NamedTemporaryFile() as tempfile: + resolve_cache_path(str(tempfile)) diff --git a/tests/tables/test_cubiecube.py b/tests/tables/test_cubiecube.py new file mode 100644 index 0000000..a48d34c --- /dev/null +++ b/tests/tables/test_cubiecube.py @@ -0,0 +1,329 @@ +from itertools import chain +from math import comb + +import pytest + +from twophase.pieces import Corner, Edge +from twophase.tables.cubiecube import ( + _MOVES, + SOLVED_CP, + SOLVED_EP, + CubieCube, + InvalidCube, + move, + verify, +) + + +@pytest.mark.parametrize( + "twist,co", + [ + [0, (0,) * 8], + [24, (0, 0, 0, 0, 2, 2, 0, 2)], + [1_234, (1, 2, 0, 0, 2, 0, 1, 0)], + [1_093, (1, 1, 1, 1, 1, 1, 1, 2)], + ], +) +def test_twist(twist, co): + cube = CubieCube(co=co) + assert cube.twist == twist + + cube = CubieCube() + cube.twist = twist + assert cube.co == co + + +@pytest.mark.parametrize("twist", [-1_000, -1, 3**7, 1_000_000]) +def test_twist_raises(twist): + cube = CubieCube() + + with pytest.raises(ValueError, match=f"{twist} is out of range for twist"): + cube.twist = twist + + +@pytest.mark.parametrize( + "flip,eo", + [ + [0, (0,) * 12], + [1_735, (1, 1, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1)], + [2_016, (1,) * 6 + (0,) * 6], + [2_047, (1,) * 12], + ], +) +def test_flip(flip, eo): + cube = CubieCube(eo=eo) + assert cube.flip == flip + + cube = CubieCube() + cube.flip = flip + assert cube.eo == eo + + +@pytest.mark.parametrize("flip", [-1_000, -1, 2**11, 1_000_000]) +def test_flip_raises(flip): + cube = CubieCube() + + with pytest.raises(ValueError, match=f"{flip} is out of range for flip"): + cube.flip = flip + + +@pytest.mark.parametrize( + "ud_slice_sorted,ep", + [ + [0, (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)], + [123, (0, 1, 2, 3, 4, 5, 10, 6, 7, 8, 9, 11)], + [4_321, (0, 1, 9, 2, 8, 10, 3, 11, 4, 5, 6, 7)], + [10_000, (10, 0, 11, 1, 2, 3, 4, 5, 6, 8, 9, 7)], + [24 * 495 - 1, (11, 10, 9, 8, 0, 1, 2, 3, 4, 5, 6, 7)], + ], +) +def test_ud_slice_sorted(ud_slice_sorted, ep): + cube = CubieCube(ep=ep) + assert cube.ud_slice_sorted == ud_slice_sorted + + cube = CubieCube() + cube.ud_slice_sorted = ud_slice_sorted + assert cube.ep == tuple(SOLVED_EP[i] for i in ep) + + +@pytest.mark.parametrize("ud_slice_sorted", [-1_000, -1, 11_880, 1_000_000]) +def test_ud_slice_sorted_raises(ud_slice_sorted): + cube = CubieCube() + + with pytest.raises( + ValueError, match=f"{ud_slice_sorted} is out of range for ud_slice_sorted" + ): + cube.ud_slice_sorted = ud_slice_sorted + + +@pytest.mark.parametrize( + "u_edges_sorted,ep", + [ + [0, (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)], + [123, (2, 0, 1, 4, 5, 3, 6, 7, 8, 9, 10, 11)], + [4_321, (4, 5, 6, 7, 1, 8, 0, 2, 9, 3, 10, 11)], + [10_000, (4, 2, 3, 5, 6, 7, 8, 9, 10, 0, 11, 1)], + [24 * 495 - 1, (4, 5, 6, 7, 8, 9, 10, 11, 3, 2, 1, 0)], + ], +) +def test_u_edges_sorted(u_edges_sorted, ep): + cube = CubieCube(ep=ep) + assert cube.u_edges_sorted == u_edges_sorted + + cube = CubieCube() + cube.u_edges_sorted = u_edges_sorted + assert cube.ep == tuple(SOLVED_EP[i] for i in ep) + + +@pytest.mark.parametrize("u_edges_sorted", [-1_000, -1, 11_880, 1_000_000]) +def test_u_edges_sorted_raises(u_edges_sorted): + cube = CubieCube() + + with pytest.raises( + ValueError, match=f"{u_edges_sorted} is out of range for u_edges_sorted" + ): + cube.u_edges_sorted = u_edges_sorted + + +@pytest.mark.parametrize( + "d_edges_sorted,ep", + [ + [0, (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)], + [123, (0, 1, 6, 2, 3, 4, 5, 7, 8, 9, 10, 11)], + [4_321, (4, 6, 11, 7, 0, 1, 2, 3, 8, 9, 5, 10)], + [10_000, (10, 11, 0, 1, 2, 4, 5, 3, 6, 8, 7, 9)], + [24 * 495 - 1, (8, 9, 10, 11, 0, 1, 2, 3, 7, 6, 5, 4)], + ], +) +def test_d_edges_sorted(d_edges_sorted, ep): + cube = CubieCube(ep=ep) + assert cube.d_edges_sorted == d_edges_sorted + + cube = CubieCube() + cube.d_edges_sorted = d_edges_sorted + assert cube.ep == tuple(SOLVED_EP[i] for i in ep) + + +@pytest.mark.parametrize("d_edges_sorted", [-1_000, -1, 11_880, 1_000_000]) +def test_d_edges_sorted_raises(d_edges_sorted): + cube = CubieCube() + + with pytest.raises( + ValueError, match=f"{d_edges_sorted} is out of range for d_edges_sorted" + ): + cube.d_edges_sorted = d_edges_sorted + + +def test_d_edges_sorted_phase_2(): + cube = CubieCube() + + for d_edges_sorted in range(24 * comb(8, 4)): + cube.d_edges_sorted = d_edges_sorted + assert cube.ep[-4:] == SOLVED_EP[-4:] + + +@pytest.mark.parametrize( + "edge8,ep", + [ + [0, tuple(range(8))], + [1_234, (2, 6, 0, 3, 4, 1, 5, 7)], + [10_000, (4, 7, 2, 3, 5, 1, 0, 6)], + [40_319, (7, 6, 5, 4, 3, 2, 1, 0)], + ], +) +def test_edge8(edge8, ep): + cube = CubieCube(ep=tuple(SOLVED_EP[i] for i in chain(ep, range(8, 12)))) + assert cube.edge8 == edge8 + + cube = CubieCube() + cube.edge8 = edge8 + assert cube.ep[:8] == tuple(SOLVED_EP[i] for i in ep) + + +@pytest.mark.parametrize("edge8", [-1_000, -1, 40_320, 1_000_000]) +def test_edge8_raises(edge8): + cube = CubieCube() + + with pytest.raises(ValueError, match=f"{edge8} is out of range for edge8"): + cube.edge8 = edge8 + + +@pytest.mark.parametrize( + "ud_slice,locs", + [ + [0, {8, 9, 10, 11}], + [42, {4, 7, 8, 11}], + [123, {3, 4, 5, 8}], + [494, {0, 1, 2, 3}], + ], +) +def test_ud_slice(ud_slice, locs): + ep = [] + slice_idx = 8 + other_idx = 0 + for i in range(12): + if i in locs: + ep.append(SOLVED_EP[slice_idx]) + slice_idx += 1 + else: + ep.append(SOLVED_EP[other_idx]) + other_idx += 1 + cube = CubieCube(ep=tuple(ep)) + print(f"{ep=}") + assert cube.ud_slice == ud_slice + + cube = CubieCube() + cube.ud_slice = ud_slice + assert set(i for i, edge in enumerate(cube.ep) if edge in SOLVED_EP[8:]) == locs + + +@pytest.mark.parametrize("ud_slice", [-1_000, -1, 495, 1_000_000]) +def test_ud_slice_raises(ud_slice): + cube = CubieCube() + + with pytest.raises(ValueError, match=f"{ud_slice} is out of range for ud_slice"): + cube.ud_slice = ud_slice + + +@pytest.mark.parametrize("ud_slice", [0, 1, 42, 123, 360, 494]) +@pytest.mark.parametrize("perm", [0, 1, 11, 23]) +def test_ud_slice_ud_slice_sorted_consistency(ud_slice, perm): + cube1 = CubieCube() + cube2 = CubieCube() + + cube1.ud_slice = ud_slice + cube2.ud_slice_sorted = 24 * ud_slice + perm + + assert cube2.ud_slice == cube1.ud_slice + if perm == 0: + assert cube1.ud_slice_sorted == cube2.ud_slice_sorted + + +@pytest.mark.parametrize( + "corner,cp", + [ + [0, tuple(range(8))], + [1_234, (2, 6, 0, 3, 4, 1, 5, 7)], + [10_000, (4, 7, 2, 3, 5, 1, 0, 6)], + [40_319, (7, 6, 5, 4, 3, 2, 1, 0)], + ], +) +def test_corner(corner, cp): + cube = CubieCube(cp=tuple(SOLVED_CP[i] for i in cp)) + assert cube.corner == corner + + cube = CubieCube() + cube.corner = corner + assert cube.cp == tuple(SOLVED_CP[i] for i in cp) + + +@pytest.mark.parametrize("corner", [-1_000, -1, 40_320, 1_000_000]) +def test_corner_raises(corner): + cube = CubieCube() + + with pytest.raises(ValueError, match=f"{corner} is out of range for corner"): + cube.corner = corner + + +def test_verify(): + # replace first edge with repeat of last + ep = SOLVED_EP[-1:] + SOLVED_EP[1:] + with pytest.raises(InvalidCube, match=" appears more than once"): + verify(CubieCube(ep=ep)) + + # rplace first corner with repeat of last + cp = SOLVED_CP[-1:] + SOLVED_CP[1:] + with pytest.raises(InvalidCube, match=" appears more than once"): + verify(CubieCube(cp=cp)) + + eo = (0,) * 11 + (1,) + with pytest.raises(InvalidCube, match="Flip error: an edge must be flipped"): + verify(CubieCube(eo=eo)) + + co = (0,) * 7 + (1,) + with pytest.raises(InvalidCube, match="Twist error: a corner must be twisted"): + verify(CubieCube(co=co)) + + # swap first and last corners + cp = SOLVED_CP[-1:] + SOLVED_CP[1:-1] + SOLVED_CP[:1] + with pytest.raises(InvalidCube, match="Parity error"): + verify(CubieCube(cp=cp)) + + +@pytest.mark.parametrize("m", range(6)) +def test_move_basic(m): + cube = move(CubieCube(), m) + assert cube == _MOVES[m] + + +def test_move_compound(): + cube = CubieCube() + for i in range(6): + cube = move(cube, i) + + assert cube.cp == ( + Corner.URF, + Corner.UFL, + Corner.UBR, + Corner.DFR, + Corner.DRB, + Corner.DLF, + Corner.ULB, + Corner.DBL, + ) + assert cube.co == (1, 1, 2, 0, 2, 1, 0, 2) + assert cube.ep == ( + Edge.FR, + Edge.FL, + Edge.BL, + Edge.UB, + Edge.DR, + Edge.DL, + Edge.DF, + Edge.DB, + Edge.UR, + Edge.UF, + Edge.UL, + Edge.BR, + ) + assert cube.eo == (0, 1, 0, 1, 1, 0, 1, 1, 1, 0, 1, 1) diff --git a/tests/tables/test_tables.py b/tests/tables/test_tables.py new file mode 100644 index 0000000..e7a60dd --- /dev/null +++ b/tests/tables/test_tables.py @@ -0,0 +1,284 @@ +import pytest + +from twophase.constants import EDGE4_MAX, EDGE8_MAX, MOVES, SYMMETRIES_D4H, TWIST_MAX +from twophase.tables.cubiecube import CubieCube, move +from twophase.tables.symmetries import Symmetries + + +def test_table_attributes(tables): + for coord in [ + "twist", + "flip", + "ud_slice_sorted", + "u_edges_sorted", + "d_edges_sorted", + "edge8", + "corner", + ]: + assert hasattr(tables, f"{coord}_move") + assert hasattr(tables, "move_conj") + for coord in ["twist", "edge8"]: + assert hasattr(tables, f"{coord}_conj") + for coord in ["corner", "flip_ud_slice"]: + assert hasattr(tables, f"{coord}_class_lookup") + assert hasattr(tables, f"{coord}_symmetry_lookup") + assert hasattr(tables, f"{coord}_representative_lookup") + + +def _test_move_table_consistency(tables, name, value, mv, phase_2_only=False): + cube = CubieCube() + setattr(cube, name, value) + + for rot in range(3): + cube = move(cube, mv) + if phase_2_only and mv % 3 != 0 and rot % 2 == 0: + assert getattr(tables, f"{name}_move")[MOVES * value + 3 * mv + rot] == 0 + else: + assert getattr(tables, f"{name}_move")[ + MOVES * value + 3 * mv + rot + ] == getattr(cube, name) + + +@pytest.mark.parametrize("twist", [0, 123, 1_000, 2_186]) +@pytest.mark.parametrize("mv", range(6)) +def test_twist_move_table(tables, twist, mv): + _test_move_table_consistency(tables, "twist", twist, mv) + + +@pytest.mark.parametrize("flip", [0, 123, 1_000, 2_047]) +@pytest.mark.parametrize("mv", range(6)) +def test_flip_move_table(tables, flip, mv): + _test_move_table_consistency(tables, "flip", flip, mv) + + +@pytest.mark.parametrize("ud_slice_sorted", [0, 123, 1_000, 10_000, 11_879]) +@pytest.mark.parametrize("mv", range(6)) +def test_ud_slice_sorted_move_table(tables, ud_slice_sorted, mv): + _test_move_table_consistency(tables, "ud_slice_sorted", ud_slice_sorted, mv) + + +@pytest.mark.parametrize("u_edges_sorted", [0, 123, 1_000, 10_000, 11_879]) +@pytest.mark.parametrize("mv", range(6)) +def test_u_edges_sorted_move_table(tables, u_edges_sorted, mv): + _test_move_table_consistency(tables, "u_edges_sorted", u_edges_sorted, mv) + + +@pytest.mark.parametrize("d_edges_sorted", [0, 123, 1_000, 10_000, 11_879]) +@pytest.mark.parametrize("mv", range(6)) +def test_d_edges_sorted_move_table(tables, d_edges_sorted, mv): + _test_move_table_consistency(tables, "d_edges_sorted", d_edges_sorted, mv) + + +@pytest.mark.parametrize("edge8", [0, 123, 1_000, 10_000, 40_319]) +@pytest.mark.parametrize("mv", range(6)) +def test_edge8_move_table(tables, edge8, mv): + _test_move_table_consistency(tables, "edge8", edge8, mv, phase_2_only=True) + + +@pytest.mark.parametrize("corner", [0, 123, 1_000, 10_000, 40_319]) +@pytest.mark.parametrize("mv", range(6)) +def test_corner_move_table(tables, corner, mv): + _test_move_table_consistency(tables, "corner", corner, mv) + + +@pytest.mark.parametrize( + "sym,move,conj", + [ + (0, 0, 0), + (0, 1, 1), + (10, 0, 9), + (10, 1, 10), + (11, 12, 17), + (42, 15, 6), + (47, 17, 15), + ], +) +def test_move_conj_table(tables, sym, move, conj): + assert tables.move_conj[MOVES * sym + move] == conj + + +def _test_conj_table_consistency(tables, name, value): + cube = CubieCube() + setattr(cube, name, value) + symmetries = Symmetries(subgroup=True) + + for s, sym in enumerate(symmetries): + conj_cube = sym * cube * symmetries.inverse[s] + assert getattr(tables, f"{name}_conj")[SYMMETRIES_D4H * value + s] == getattr( + conj_cube, name + ) + + +@pytest.mark.parametrize("twist", [0, 123, 1_000, 2_186]) +def test_twist_conj_table(tables, twist): + _test_conj_table_consistency(tables, "twist", twist) + + +@pytest.mark.parametrize("edge8", [0, 123, 1_000, 10_000, 40_319]) +def test_edge8_conj_table(tables, edge8): + _test_conj_table_consistency(tables, "edge8", edge8) + + +def _test_symmetry_tables(tables, name, coord, class_index, symmetry, representative): + assert getattr(tables, f"{name}_class_lookup")[coord] == class_index + assert getattr(tables, f"{name}_symmetry_lookup")[coord] == symmetry + assert ( + getattr(tables, f"{name}_representative_lookup")[class_index] == representative + ) + + +@pytest.mark.parametrize( + "corner,class_index,symmetry,representative", + [ + (0, 0, 0, 0), + (1_234, 165, 2, 239), + (10_000, 220, 9, 350), + (40_000, 189, 9, 319), + (40_319, 2_753, 2, 28_783), + ], +) +def test_corner_symmetry_tables(tables, corner, class_index, symmetry, representative): + _test_symmetry_tables( + tables, "corner", corner, class_index, symmetry, representative + ) + + +@pytest.mark.parametrize( + "flip_ud_slice,class_index,symmetry,representative", + [ + (0, 0, 0, 0), + (1_234, 103, 2, 429), + (10_000, 4_090, 1, 8_000), + (100_000, 11_380, 7, 40_382), + (1_000_000, 16_296, 11, 66_094), + (1_013_759, 18_061, 10, 143_232), + ], +) +def test_flip_ud_slice_symmetry_tables( + tables, flip_ud_slice, class_index, symmetry, representative +): + _test_symmetry_tables( + tables, "flip_ud_slice", flip_ud_slice, class_index, symmetry, representative + ) + + +def _test_symmetry_tables_consistency(tables, name, coord): + symmetries = Symmetries(subgroup=True) + + # initialise cube with given coordinate + cube = CubieCube() + setattr(cube, name, coord) + # find the representative of the original cube's equivalence class + rep = CubieCube() + setattr( + rep, + name, + getattr(tables, f"{name}_representative_lookup")[ + getattr(tables, f"{name}_class_lookup")[coord] + ], + ) + + # the class of the cube and its representative should match + assert ( + getattr(tables, f"{name}_class_lookup")[coord] + == getattr(tables, f"{name}_class_lookup")[getattr(rep, name)] + ) + + # conjugating cube by the symmetry should recover the representative + sym_index = getattr(tables, f"{name}_symmetry_lookup")[coord] + conj_cube = symmetries[sym_index] * cube * symmetries.inverse[sym_index] + assert getattr(conj_cube, name) == getattr(rep, name) + + +@pytest.mark.parametrize("corner", [0, 123, 1_234, 12_345, 34_567, 40_000, 40_319]) +def test_corner_symmetry_tables_consistency(tables, corner): + _test_symmetry_tables_consistency(tables, "corner", corner) + + +@pytest.mark.parametrize( + "flip_ud_slice", [0, 123, 1_234, 12_345, 123_456, 1_000_000, 1_013_759] +) +def test_flip_ud_slice_symmetry_tables_consistency(tables, flip_ud_slice): + _test_symmetry_tables_consistency(tables, "flip_ud_slice", flip_ud_slice) + + +def _test_pruning_table(tables, name, coord1, coord2, max_val2, pruning_value): + assert getattr(tables, f"{name}_prune")[max_val2 * coord1 + coord2] == pruning_value + + +@pytest.mark.parametrize( + "flip_ud_slice_class,twist,pruning_value", + [ + (0, 0, 0), + (1, 0, 1), + (10, 20, 0), + (1_234, 1_234, 2), + (60_000, 2_000, 0), + (64_429, 2_186, 2), + ], +) +def test_flip_ud_slice_class_twist_pruning_table( + tables, flip_ud_slice_class, twist, pruning_value +): + _test_pruning_table( + tables, + "flip_ud_slice_class_twist", + flip_ud_slice_class, + twist, + TWIST_MAX, + pruning_value, + ) + + +@pytest.mark.parametrize( + "corner_class,edge8,pruning_value", + [ + (0, 0, 0), + (39, 23_624, 1), + (949, 25_039, 1), + (1_145, 35_854, 1), + (1_526, 28_658, 1), + (1_549, 6_398, 0), + (2_423, 9_480, 1), + ], +) +def test_corner_class_edge8_pruning_table(tables, corner_class, edge8, pruning_value): + _test_pruning_table( + tables, "corner_class_edge8", corner_class, edge8, EDGE8_MAX, pruning_value + ) + + +@pytest.mark.parametrize( + "edge_merge,edge8", + [ + (0, 0), + (123, 845), + (1_234, 6_517), + (12_345, 8_145), + (40_000, 30_112), + (40_319, 40_319), + ], +) +def test_edge_merge_table(tables, edge_merge, edge8): + assert tables.edge_merge[edge_merge] == edge8 + + +@pytest.mark.parametrize("edge8", [0, 123, 1_234, 12_345, 40_000, 40_319]) +def test_edge_merge_table_consistency(tables, edge8): + cube = CubieCube() + cube.edge8 = edge8 + + assert ( + tables.edge_merge[ + EDGE4_MAX * cube.u_edges_sorted + cube.d_edges_sorted % EDGE4_MAX + ] + == edge8 + ) + + +@pytest.mark.parametrize("depth", range(20)) +def test_depth_lookup_table(tables, depth): + for offset in range(-1, 2): + new_depth = depth + offset + if new_depth >= 0: + assert tables.depth_lookup[3 * depth + new_depth % 3] == new_depth diff --git a/tests/test_cubestring.py b/tests/test_cubestring.py new file mode 100644 index 0000000..0284908 --- /dev/null +++ b/tests/test_cubestring.py @@ -0,0 +1,96 @@ +import pytest + +from twophase.cubestring import parse +from twophase.exceptions import InvalidCube +from twophase.pieces import Corner, Edge +from twophase.tables.cubiecube import CubieCube + +SOLVED_STRING = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" + + +class TestSolvedCube: + def test_parse_solved(self): + cc = parse(SOLVED_STRING) + assert cc == CubieCube() + + def test_case_insensitive(self): + cc = parse(SOLVED_STRING.lower()) + assert cc == CubieCube() + + +class TestScrambledCubes: + def test_single_move_u(self): + """U move cycles four corners and four edges on the U face.""" + cc = parse("UUUUUUUUUBBBRRRRRRRRRFFFFFFDDDDDDDDDFFFLLLLLLLLLBBBBBB") + # corners: URF→UBR→ULB→UFL (cycle of 4) + assert cc.cp == ( + Corner.UBR, + Corner.URF, + Corner.UFL, + Corner.ULB, + Corner.DFR, + Corner.DLF, + Corner.DBL, + Corner.DRB, + ) + # U moves don't twist corners + assert cc.co == (0, 0, 0, 0, 0, 0, 0, 0) + # edges: UR→UB→UL→UF (cycle of 4) + assert cc.ep == ( + Edge.UB, + Edge.UR, + Edge.UF, + Edge.UL, + Edge.DR, + Edge.DF, + Edge.DL, + Edge.DB, + Edge.FR, + Edge.FL, + Edge.BL, + Edge.BR, + ) + # U moves don't flip edges + assert cc.eo == (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + + def test_r2_u_l(self): + """R2 U L scramble from the debug notebook.""" + cc = parse("BUUBUULDDFBBRRRRRRURRUFBDFBRDUFDUFDULLFLLFLLBLLDFBDFBD") + assert cc.twist == 412 + assert cc.flip == 0 + assert cc.corner == 36781 + + def test_complex_scramble(self): + """A fully scrambled cube should parse and verify successfully.""" + cc = parse("BUFDUFDLLBLDBRLFDBLDURFRBUULFLLDBRURRFFRLFUUDRRUBBDDBF") + # just check it parses and produces a valid cube (verify is called + # internally by parse) + assert cc is not None + + +class TestValidation: + def test_wrong_length(self): + with pytest.raises(InvalidCube, match="54 characters"): + parse("UUUUUU") + + def test_invalid_character(self): + with pytest.raises(InvalidCube, match="invalid character"): + parse("X" + SOLVED_STRING[1:]) + + def test_wrong_colour_count(self): + # replace one U with an R (10 R's, 8 U's) + bad = "RUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" + with pytest.raises(InvalidCube, match="exactly 9 times"): + parse(bad) + + def test_impossible_twist(self): + """A single twisted corner is not solvable.""" + # swap two facelets of the URF corner to create an impossible twist + with pytest.raises(InvalidCube): + parse("UUUUUUUURRRRRRRRRFUFFFFFFFDDDDDDDDDBLLLLLLLLBBBBBBBBB") + + def test_swapped_edges(self): + """A single edge swap is not solvable (parity error).""" + # swap UR and UF edges: put F colour where R should be on U face + with pytest.raises(InvalidCube): + parse("UUUUUFUURFRRRRRRRUFFFFFFFDDDDDDDDDDLLLLLLLLLBBBBBBBBB") diff --git a/tests/test_interface.py b/tests/test_interface.py new file mode 100644 index 0000000..b4974db --- /dev/null +++ b/tests/test_interface.py @@ -0,0 +1,34 @@ +import pytest + +from twophase import Move, format_moves, parse, solve, solve_progressively +from twophase.exceptions import InvalidCube + +SOLVED = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" +U_TURN = "UUUUUUUUUBBBRRRRRRRRRFFFFFFDDDDDDDDDFFFLLLLLLLLLBBBBBB" + + +def test_format_moves_uses_standard_notation(): + assert format_moves([Move.L3, Move.U3, Move.R2]) == "L' U' R2" + + +def test_parse_is_available_from_the_package(): + assert parse(SOLVED).corner == 0 + + +def test_solve_returns_a_formatted_solution(): + assert solve(U_TURN, timeout=5) == "U'" + + +def test_solve_returns_none_when_the_bound_is_too_short(): + assert solve(U_TURN, max_length=0, timeout=5) is None + + +def test_solve_progressively_formats_each_solution(): + solutions = list(solve_progressively(U_TURN, timeout=5)) + assert solutions[-1] == "U'" + assert all(isinstance(solution, str) for solution in solutions) + + +def test_solve_rejects_invalid_facelets(): + with pytest.raises(InvalidCube, match="54 characters"): + solve("not a cube") diff --git a/tests/test_solver.py b/tests/test_solver.py new file mode 100644 index 0000000..062afb1 --- /dev/null +++ b/tests/test_solver.py @@ -0,0 +1,137 @@ +import random +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from twophase.pieces import Move +from twophase.solve.solver import Solver +from twophase.tables.cubiecube import CubieCube, Moves + + +@pytest.fixture(scope="module") +def solver(): + return Solver() + + +CUBE_MOVES = Moves() + + +def _apply_moves(cube: CubieCube, moves: list[Move]) -> CubieCube: + """Apply a sequence of moves to a cube and return the result.""" + for m in moves: + mv = CUBE_MOVES[m] + assert isinstance(mv, CubieCube) + cube = cube.multiply(mv) + return cube + + +def _is_solved(cube: CubieCube) -> bool: + return cube == CubieCube() + + +class TestSolvedCube: + def test_returns_empty_list(self, solver): + solution = solver.solve(CubieCube()) + assert solution == [] + + +class TestSimpleCubes: + @pytest.mark.parametrize( + "scramble", + [[Move.U1], [Move.R2], [Move.F3], [Move.R2, Move.U1, Move.L1]], + ids=["U", "R2", "F'", "R2 U L"], + ) + def test_finds_solution(self, solver, scramble): + cube = _apply_moves(CubieCube(), scramble) + solution = solver.solve(cube, timeout=5) + + assert solution is not None + assert _is_solved(_apply_moves(cube, solution)) + + def test_r2_u_l_optimal(self, solver): + """R2 U L should be solvable in 3 moves: L' U' R2.""" + cube = _apply_moves(CubieCube(), [Move.R2, Move.U1, Move.L1]) + *_, solution = solver.solve_progressively(cube, timeout=5) + + assert len(solution) == 3 + + +class TestComplexCubes: + @pytest.mark.parametrize("seed", range(5)) + def test_random_scramble_is_solvable(self, solver, seed): + rng = random.Random(seed) + scramble = [Move(rng.randint(0, 17)) for _ in range(25)] + cube = _apply_moves(CubieCube(), scramble) + solution = solver.solve(cube, timeout=10) + + assert solution is not None + assert _is_solved(_apply_moves(cube, solution)) + assert len(solution) <= 20 + + +class TestMaxLength: + def test_solved_cube_with_zero_length_bound(self, solver): + assert solver.solve(CubieCube(), max_length=0) == [] + + def test_exact_bound_may_be_spent_entirely_in_phase_1(self, solver): + cube = _apply_moves(CubieCube(), [Move.F1]) + solution = solver.solve(cube, max_length=1, timeout=5) + + assert solution == [Move.F3] + + def test_respects_max_length(self, solver): + cube = _apply_moves(CubieCube(), [Move.R2, Move.U1, Move.L1]) + solution = solver.solve(cube, max_length=3, timeout=5) + + assert solution is not None + assert len(solution) <= 3 + + def test_returns_none_when_max_length_too_short(self, solver): + cube = _apply_moves(CubieCube(), [Move.R2, Move.U1, Move.L1]) + # max_length=1 is too short for a 3-move solution + solution = solver.solve(cube, max_length=1, timeout=5) + + assert solution is None + + +class TestSearchVariants: + def test_each_variant_maps_its_solution_back_to_the_original_cube(self, solver): + cube = _apply_moves(CubieCube(), [Move.R2, Move.U1, Move.L1]) + variants = solver._coordinates.search_variants(cube) + + assert len(variants) == 6 + for variant in variants: + solution = solver.solve(variant.cube, max_length=3, timeout=5) + assert solution is not None + + restored = solver._coordinates.restore_solution(solution, variant) + assert _is_solved(_apply_moves(cube, restored)) + + +class TestConcurrentUse: + def test_search_state_is_not_shared_between_calls(self, solver): + cubes = [] + for seed in range(3): + rng = random.Random(seed) + scramble = [Move(rng.randint(0, 17)) for _ in range(25)] + cubes.append(_apply_moves(CubieCube(), scramble)) + + with ThreadPoolExecutor(max_workers=len(cubes)) as executor: + solutions = list(executor.map(solver.solve, cubes)) + + for cube, solution in zip(cubes, solutions): + assert solution is not None + assert _is_solved(_apply_moves(cube, solution)) + + +class TestTimeout: + def test_returns_none_when_no_solution_found_in_time(self, solver): + rng = random.Random(42) + scramble = [Move(rng.randint(0, 17)) for _ in range(25)] + cube = _apply_moves(CubieCube(), scramble) + # very short timeout — may not find anything + solution = solver.solve(cube, timeout=0.0001) + + # either None or a valid solution + if solution is not None: + assert _is_solved(_apply_moves(cube, solution)) diff --git a/tests/test_symmetries.py b/tests/test_symmetries.py new file mode 100644 index 0000000..f67365a --- /dev/null +++ b/tests/test_symmetries.py @@ -0,0 +1,65 @@ +import pytest + +from twophase.tables.cubiecube import ( + SOLVED_CO, + SOLVED_CP, + SOLVED_EO, + SOLVED_EP, + CubieCube, +) +from twophase.tables.symmetries import Symmetries, cube_symmetries + + +def test_symmetries(): + s = Symmetries() + + assert hasattr(s, "_symmetries") + assert len(s._symmetries) == len(s) == 48 + assert hasattr(s, "_inverse_symmetries") + assert len(s._inverse_symmetries) == len(s) + + +def test_inverse_symmetries(): + symmetries = Symmetries() + + for i, sym in enumerate(symmetries): + cube = sym * symmetries.inverse[i] + assert ( + cube.co == SOLVED_CO + and cube.cp == SOLVED_CP + and cube.eo == SOLVED_EO + and cube.ep == SOLVED_EP + ) + + +@pytest.mark.parametrize( + ["cp", "co", "ep", "eo", "symmetries"], + [ + (range(8), (0,) * 8, range(12), (0,) * 12, set(range(96))), + ( + (4, 7, 3, 5, 6, 2, 1, 0), + (0, 0, 1, 1, 1, 2, 0, 1), + (0, 1, 6, 2, 3, 5, 4, 7, 8, 9, 10, 11), + (0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1), + {0}, + ), + ( + range(8), + (0,) * 8, + (4, 5, 2, 3, 6, 7, 0, 1, 9, 10, 8, 11), + (0,) * 12, + {0, 74}, + ), + ], +) +def test_cube_symmetries(cp, co, ep, eo, symmetries): + c = CubieCube( + cp=tuple(SOLVED_CP[i] for i in cp), + co=co, + ep=tuple(SOLVED_EP[i] for i in ep), + eo=eo, + ) + + syms = cube_symmetries(c) + assert isinstance(syms, list) + assert set(syms) == symmetries diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..8edab64 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,5 @@ +from twophase import __version__ + + +def test_version(): + assert isinstance(__version__, str) From 83615aef4951cca1dbc430950ac9d2d0782dbd4b Mon Sep 17 00:00:00 2001 From: tcbegley Date: Mon, 31 Aug 2026 21:44:53 +0100 Subject: [PATCH 6/6] Polish user-facing documentation --- README.md | 104 ++++++++++++++++++++++++++++--------- src/twophase/cubestring.py | 12 ++--- 2 files changed, 86 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index bcedd01..46ce5fc 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,101 @@ # twophase -An educational Python implementation of Herbert Kociemba's two-phase algorithm -for solving a Rubik's Cube. +A pure-Python implementation of Herbert Kociemba's two-phase algorithm for +solving a Rubik's Cube. -The project models a cube as its movable corner and edge pieces, represents -states with compact coordinates, and uses generated move and pruning tables to -search for solutions. The implementation favors readable, well-tested Python -alongside the optimizations needed for a practical solver. +## Installation + +Requires Python 3.12 or later. Add it to your `uv` project directly from GitHub: + +```sh +uv add git+https://github.com/tcbegley/cube-solver.git +``` + +Without uv, install it with pip instead: + +```sh +pip install git+https://github.com/tcbegley/cube-solver.git +``` ## Usage -Pass a 54-character facelet string in `U R F D L B` face order to `solve`: +Pass `solve` a 54-character string that describes the cube's stickers: ```python from twophase import solve -cube = "UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB" -assert solve(cube) == "" +cube = "UUUUUUUUUBBBRRRRRRRRRFFFFFFDDDDDDDDDFFFLLLLLLLLLBBBBBB" +print(solve(cube)) # U' ``` -`solve` returns a space-separated sequence in standard cube notation, or -`None` when a solution cannot be found before the requested length or time -limit. For lower-level use, `parse` converts a facelet string to a `CubieCube`, -`Solver` returns `Move` values, and `format_moves` renders those values. +> [!IMPORTANT] +> The first solve generates the coordinate and pruning tables used by the +> search. This can take several minutes. The tables are cached for later runs; +> pass `cache_path` to `solve` or `solve_progressively` to store them elsewhere. + +The result is a space-separated sequence in standard cube notation (`U`, `R2`, +`F'`, and so on). `solve` returns `None` when it cannot find a solution within +its limits; use `max_length` and `timeout` to adjust the default 20-move and +10-second limits. ```python -from twophase import Solver, format_moves, parse +solution = solve(cube, max_length=22, timeout=30) +``` -moves = Solver().solve(parse(cube)) -print(format_moves(moves or [])) +### Cube strings + +A cube string contains the sticker colours `U`, `R`, `F`, `D`, `L`, and `B` in +this order: U1–U9, R1–R9, F1–F9, D1–D9, L1–L9, B1–B9. + +```text + |------------| + |-U1--U2--U3-| + |------------| + |-U4--U5--U6-| + |------------| + |-U7--U8--U9-| +|------------|------------|------------|------------| +|-L1--L2--L3-|-F1--F2--F3-|-R1--R2--R3-|-B1--B2--B3-| +|------------|------------|------------|------------| +|-L4--L5--L6-|-F4--F5--F6-|-R4--R5--R6-|-B4--B5--B6-| +|------------|------------|------------|------------| +|-L7--L8--L9-|-F7--F8--F9-|-R7--R8--R9-|-B7--B8--B9-| +|------------|------------|------------|------------| + |-D1--D2--D3-| + |------------| + |-D4--D5--D6-| + |------------| + |-D7--D8--D9-| + |------------| ``` -The first solve generates lookup tables. Pass `cache_path` to `Solver` or the -high-level helpers to choose where they are stored. +For example, a solved cube is: -## Development +```text +UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB +``` -This repository uses [uv](https://docs.astral.sh/uv/) to manage its Python -environment and lock file: +### Finding shorter solutions -```sh -uv sync --all-groups -uv run pytest +`solve_progressively` yields increasingly shorter solutions until it reaches +the time limit: + +```python +from twophase import solve_progressively + +for solution in solve_progressively(cube, timeout=30): + print(solution) +``` + +### Lower-level interface + +For applications that need the cube model or moves rather than a formatted +string, use `parse`, `Solver`, and `format_moves`: + +```python +from twophase import Solver, format_moves, parse + +moves = Solver().solve(parse(cube)) +if moves is not None: + print(format_moves(moves)) ``` diff --git a/src/twophase/cubestring.py b/src/twophase/cubestring.py index 13c2957..dfb55f1 100644 --- a/src/twophase/cubestring.py +++ b/src/twophase/cubestring.py @@ -7,15 +7,15 @@ Face layout: Facelet numbering: - U U U 1 2 3 - U U U 4 5 6 - U U U 7 8 9 + U U U 1 2 3 + U U U 4 5 6 + U U U 7 8 9 L L L F F F R R R B B B L L L F F F R R R B B B L L L F F F R R R B B B - D D D - D D D - D D D + D D D + D D D + D D D Valid characters are the face letters ``U R F D L B`` (case-insensitive).