File size: 7,687 Bytes
d594310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
"""
Solve a Rubik's Cube with tiny-cube-solver.

The network does not write moves. It reads one cube and guesses how many moves
it is from solved. This script tries every move from the current positions,
asks the network to score all the results, keeps the best `--beam` of them, and
repeats until one of them is the solved cube. Every move is applied by the code
below, and the answer is checked before it is printed.

    pip install torch transformers safetensors huggingface_hub
    python solve.py --scramble "R U F' D2 L B'"
    python solve.py --facelets UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB

Facelets use the same 54-character layout as the `kociemba` package: faces in
the order U R F D L B, nine stickers each, each sticker named by the face whose
centre has its colour.

Search size (`--beam`) is the one setting that matters:
  64    solves every cube scrambled with 10 moves or fewer; ~25% of full scrambles
  1024  solves ~96% of full scrambles; about 16x the work
A graphics card is strongly recommended above 10 moves.
"""
import argparse
import json
import sys
import time

import torch

# Sticker permutation for one clockwise quarter turn of each face.
QT = {
  "U": [6, 3, 0, 7, 4, 1, 8, 5, 2, 45, 46, 47, 12, 13, 14, 15, 16, 17, 9, 10, 11, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 18, 19, 20, 39, 40, 41, 42, 43, 44, 36, 37, 38, 48, 49, 50, 51, 52, 53],
  "R": [0, 1, 20, 3, 4, 23, 6, 7, 26, 15, 12, 9, 16, 13, 10, 17, 14, 11, 18, 19, 29, 21, 22, 32, 24, 25, 35, 27, 28, 51, 30, 31, 48, 33, 34, 45, 36, 37, 38, 39, 40, 41, 42, 43, 44, 8, 46, 47, 5, 49, 50, 2, 52, 53],
  "F": [0, 1, 2, 3, 4, 5, 44, 41, 38, 6, 10, 11, 7, 13, 14, 8, 16, 17, 24, 21, 18, 25, 22, 19, 26, 23, 20, 15, 12, 9, 30, 31, 32, 33, 34, 35, 36, 37, 27, 39, 40, 28, 42, 43, 29, 45, 46, 47, 48, 49, 50, 51, 52, 53],
  "D": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 24, 25, 26, 18, 19, 20, 21, 22, 23, 42, 43, 44, 33, 30, 27, 34, 31, 28, 35, 32, 29, 36, 37, 38, 39, 40, 41, 51, 52, 53, 45, 46, 47, 48, 49, 50, 15, 16, 17],
  "L": [53, 1, 2, 50, 4, 5, 47, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 0, 19, 20, 3, 22, 23, 6, 25, 26, 18, 28, 29, 21, 31, 32, 24, 34, 35, 42, 39, 36, 43, 40, 37, 44, 41, 38, 45, 46, 33, 48, 49, 30, 51, 52, 27],
  "B": [11, 14, 17, 3, 4, 5, 6, 7, 8, 9, 10, 35, 12, 13, 34, 15, 16, 33, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 36, 39, 42, 2, 37, 38, 1, 40, 41, 0, 43, 44, 51, 48, 45, 52, 49, 46, 53, 50, 47],
}
SOLVED = "".join("URFDLB"[i // 9] for i in range(54))
MOVES = [f + s for f in "URFDLB" for s in ("", "'", "2")]
_TURNS = {"": 1, "'": 3, "2": 2}

# Token ids the network was trained on: [BOS] + 54 sticker tokens + [SEP].
# The sticker ids are not contiguous; they are what the training tokenizer
# produced, and every weight in model.safetensors assumes exactly these.
BOS, SEP, PAD = 1, 2, 0
COLOR_ID = dict(zip("URFDLB", [10, 13, 16, 19, 22, 25]))


def apply_move(state: str, move: str) -> str:
    perm = QT[move[0]]
    for _ in range(_TURNS[move[1:]]):
        state = "".join(state[perm[i]] for i in range(54))
    return state


def apply_sequence(state: str, moves) -> str:
    for m in moves:
        state = apply_move(state, m)
    return state


class ValueNet(torch.nn.Module):
    """A small Llama-shaped encoder over the 54 stickers, averaged, to one number."""

    def __init__(self, hidden, layers, heads, vocab_size=28, max_positions=83):
        super().__init__()
        from transformers import LlamaConfig, LlamaModel
        cfg = LlamaConfig(
            vocab_size=vocab_size, hidden_size=hidden, intermediate_size=hidden * 4,
            num_hidden_layers=layers, num_attention_heads=heads,
            num_key_value_heads=heads, max_position_embeddings=max_positions,
            pad_token_id=PAD,
        )
        self.encoder = LlamaModel(cfg)
        self.head = torch.nn.Linear(hidden, 1)

    def forward(self, ids):
        h = self.encoder(input_ids=ids).last_hidden_state.mean(dim=1)
        return self.head(h).squeeze(-1)


def load(repo_or_dir="briscoooe/tiny-cube-solver", device="cpu"):
    """Loads the network from a local folder or from the Hugging Face Hub."""
    import os
    from safetensors.torch import load_file
    if os.path.isdir(repo_or_dir):
        cfg_path = os.path.join(repo_or_dir, "config.json")
        weights = os.path.join(repo_or_dir, "model.safetensors")
    else:
        from huggingface_hub import hf_hub_download
        cfg_path = hf_hub_download(repo_or_dir, "config.json")
        weights = hf_hub_download(repo_or_dir, "model.safetensors")
    cfg = json.load(open(cfg_path))
    net = ValueNet(cfg["hidden"], cfg["layers"], cfg["heads"],
                   cfg["vocab_size"], cfg["max_positions"])
    net.load_state_dict(load_file(weights))
    return net.to(device).eval()


@torch.no_grad()
def distance(net, states, device, batch_size=1024):
    """The network's guess of moves-to-solved for each state."""
    out = []
    for i in range(0, len(states), batch_size):
        ids = torch.tensor([[BOS] + [COLOR_ID[c] for c in s] + [SEP]
                            for s in states[i:i + batch_size]], device=device)
        out.extend(net(ids).float().clamp(min=0.0).tolist())
    return out


def solve(net, state, device="cpu", beam=64, max_steps=26):
    """Beam search over positions. Returns the solving moves, or None."""
    if state == SOLVED:
        return []
    frontier = [(state, [])]
    seen = {state}
    for _ in range(max_steps):
        children, paths = [], []
        for s, path in frontier:
            for mv in MOVES:
                nxt = apply_move(s, mv)
                if nxt in seen:
                    continue
                if nxt == SOLVED:
                    return path + [mv]
                seen.add(nxt)
                children.append(nxt)
                paths.append(path + [mv])
        if not children:
            return None
        h = distance(net, children, device)
        ranked = sorted(zip(h, children, paths), key=lambda x: x[0])
        frontier = [(c, p) for _, c, p in ranked[:beam]]
    return None


def main():
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    g = p.add_mutually_exclusive_group(required=True)
    g.add_argument("--scramble", help='moves applied to a solved cube, e.g. "R U F\' D2"')
    g.add_argument("--facelets", help="54-character cube state, kociemba layout")
    p.add_argument("--beam", type=int, default=64)
    p.add_argument("--model", default="briscoooe/tiny-cube-solver",
                   help="Hub repo id or local folder")
    args = p.parse_args()

    if args.scramble:
        moves = args.scramble.split()
        bad = [m for m in moves if m not in MOVES]
        if bad:
            sys.exit(f"unknown moves: {bad}; use {' '.join(MOVES)}")
        state = apply_sequence(SOLVED, moves)
    else:
        state = args.facelets.strip()
        if len(state) != 54 or set(state) - set("URFDLB") or any(state.count(c) != 9 for c in "URFDLB"):
            sys.exit("facelets must be 54 characters, nine each of U R F D L B")

    device = "cuda" if torch.cuda.is_available() else "cpu"
    net = load(args.model, device)
    t0 = time.time()
    moves = solve(net, state, device, beam=args.beam)
    secs = time.time() - t0
    if moves is None or apply_sequence(state, moves) != SOLVED:
        print(f"not solved (beam {args.beam}, {secs:.1f}s). Try a larger --beam.")
        sys.exit(1)
    print(" ".join(moves))
    print(f"{len(moves)} moves, {secs:.1f}s on {device}, beam {args.beam}", file=sys.stderr)


if __name__ == "__main__":
    main()