briscoooe commited on
Commit
d594310
·
verified ·
1 Parent(s): 853684e

tiny-cube-solver: value network, solve.py and model card

Browse files
Files changed (4) hide show
  1. README.md +113 -0
  2. config.json +8 -0
  3. model.safetensors +3 -0
  4. solve.py +172 -0
README.md ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ library_name: pytorch
3
+ tags:
4
+ - rubiks-cube
5
+ - search
6
+ - value-iteration
7
+ - reinforcement-learning
8
+ pipeline_tag: other
9
+ ---
10
+
11
+ # tiny-cube-solver
12
+
13
+ A 33.6M-parameter network that guesses how many moves a Rubik's Cube is from
14
+ solved, plus a search that uses the guess to find the moves. It was trained from
15
+ nothing: no solver, no example solutions, no human data.
16
+
17
+ - Solves **96%** of fully scrambled cubes (25 cubes, 20-move scrambles)
18
+ - Solves **every** cube scrambled with 10 moves or fewer
19
+ - Built as a control for [RubiksBench](https://brianbriscoe.dev/lab/rubiksbench),
20
+ where the best frontier language models solve 50 to 80% of scrambles when they
21
+ write the whole answer in one go
22
+
23
+ Write-up: https://brianbriscoe.dev/lab/tiny-cube-solver (coming soon)
24
+
25
+ ## Try it
26
+
27
+ ```sh
28
+ pip install torch transformers safetensors huggingface_hub
29
+ python solve.py --scramble "R U F' D2 L B'"
30
+ ```
31
+
32
+ or with a cube state in the `kociemba` layout (faces U R F D L B, nine stickers
33
+ each):
34
+
35
+ ```sh
36
+ python solve.py --facelets <54 characters>
37
+ ```
38
+
39
+ `solve.py` has no other dependencies. It downloads `config.json` and
40
+ `model.safetensors` from this repo the first time.
41
+
42
+ ## How it works
43
+
44
+ The network does not write moves. It reads the 54 stickers of one cube and
45
+ returns one number: its guess of the distance to solved.
46
+
47
+ `solve.py` then searches:
48
+
49
+ 1. Apply each of the 18 moves to every position it is keeping.
50
+ 2. Ask the network to score all the new positions.
51
+ 3. Keep the best `--beam` of them, and repeat.
52
+ 4. Stop when one of them is the solved cube.
53
+
54
+ Every move is applied by ordinary code, and the answer is checked before it is
55
+ printed. The network only decides which positions are worth keeping.
56
+
57
+ ### Training: the model makes its own answers
58
+
59
+ The model was trained by approximate value iteration (the method behind
60
+ [DeepCubeA](https://www.nature.com/articles/s42256-019-0070-z)). It starts from
61
+ one free fact: the solved cube is 0 moves from solved. A cube is then 1 more
62
+ than its best neighbour. The network learns from its own previous guesses, and
63
+ the correct answers spread outward from the solved cube, one move further each
64
+ round. No solver is involved at any point.
65
+
66
+ An earlier version with the identical network was trained on distances from the
67
+ Kociemba solver instead. It solved only 16% of full scrambles. Kociemba returns
68
+ good solutions, not the shortest, and 47% of random cubes got the label "21", so
69
+ there was nothing to learn from above distance 15. Changing only the labels took
70
+ full scrambles from 16% to 96%.
71
+
72
+ ## Results
73
+
74
+ Canonical RubiksBench scrambles (the same cubes every language model on the
75
+ board is tested on), 25 cubes per scramble length:
76
+
77
+ | Scramble length | beam 64 | beam 1024 |
78
+ |---|---|---|
79
+ | 3 | 100% | 100% |
80
+ | 6 | 100% | 100% |
81
+ | 10 | 100% | 100% |
82
+ | 15 | 28% | 100% |
83
+ | 20 | 24% | 96% |
84
+
85
+ Past about 15 moves, every scramble is fully mixed, so 25, 50 or 100 moves are
86
+ no harder than 20. Beam 1024 takes about 30 seconds per full scramble on one
87
+ consumer graphics card. Beam 64 on a processor with no graphics card took about 5 seconds for a
88
+ 3-move scramble and about 50 seconds for a 6-move scramble; it is not practical
89
+ for full scrambles.
90
+
91
+ ## Model
92
+
93
+ - A Llama-shaped encoder, trained from scratch: 8 layers, width 512, 8 heads.
94
+ The architecture only; no Llama weights.
95
+ - Input: `[BOS]` + 54 sticker tokens + `[SEP]`. Output: the mean of the final
96
+ layer, through one linear layer, to one number.
97
+ - 33,577,985 parameters, float32.
98
+
99
+ ## Things that give much lower numbers
100
+
101
+ | What you did | What you get at 20 moves |
102
+ |---|---|
103
+ | Beam 64 instead of 1024 | about 24% |
104
+ | Always take the best-scored move, no search | 34% even at 10 moves |
105
+ | Judge the network by its average error | about 1.5 moves, which looks poor; the search only needs the correct ranking of the 18 neighbours |
106
+
107
+ ## Limits
108
+
109
+ - It is not fast. The Kociemba two-phase solver solves any cube in milliseconds.
110
+ This model exists to show that a small network can learn the task at all.
111
+ - Solutions are not guaranteed to be shortest. Full scrambles average about 21
112
+ moves.
113
+ - 3x3x3 only.
config.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "hidden": 512,
3
+ "layers": 8,
4
+ "heads": 8,
5
+ "vocab_size": 28,
6
+ "max_positions": 83,
7
+ "training_step": 27000
8
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:16ed8f2bef7283f875e287ac3f434f706c72133e75a5222f40d29e185f001519
3
+ size 134320420
solve.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Solve a Rubik's Cube with tiny-cube-solver.
3
+
4
+ The network does not write moves. It reads one cube and guesses how many moves
5
+ it is from solved. This script tries every move from the current positions,
6
+ asks the network to score all the results, keeps the best `--beam` of them, and
7
+ repeats until one of them is the solved cube. Every move is applied by the code
8
+ below, and the answer is checked before it is printed.
9
+
10
+ pip install torch transformers safetensors huggingface_hub
11
+ python solve.py --scramble "R U F' D2 L B'"
12
+ python solve.py --facelets UUUUUUUUURRRRRRRRRFFFFFFFFFDDDDDDDDDLLLLLLLLLBBBBBBBBB
13
+
14
+ Facelets use the same 54-character layout as the `kociemba` package: faces in
15
+ the order U R F D L B, nine stickers each, each sticker named by the face whose
16
+ centre has its colour.
17
+
18
+ Search size (`--beam`) is the one setting that matters:
19
+ 64 solves every cube scrambled with 10 moves or fewer; ~25% of full scrambles
20
+ 1024 solves ~96% of full scrambles; about 16x the work
21
+ A graphics card is strongly recommended above 10 moves.
22
+ """
23
+ import argparse
24
+ import json
25
+ import sys
26
+ import time
27
+
28
+ import torch
29
+
30
+ # Sticker permutation for one clockwise quarter turn of each face.
31
+ QT = {
32
+ "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],
33
+ "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],
34
+ "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],
35
+ "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],
36
+ "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],
37
+ "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],
38
+ }
39
+ SOLVED = "".join("URFDLB"[i // 9] for i in range(54))
40
+ MOVES = [f + s for f in "URFDLB" for s in ("", "'", "2")]
41
+ _TURNS = {"": 1, "'": 3, "2": 2}
42
+
43
+ # Token ids the network was trained on: [BOS] + 54 sticker tokens + [SEP].
44
+ # The sticker ids are not contiguous; they are what the training tokenizer
45
+ # produced, and every weight in model.safetensors assumes exactly these.
46
+ BOS, SEP, PAD = 1, 2, 0
47
+ COLOR_ID = dict(zip("URFDLB", [10, 13, 16, 19, 22, 25]))
48
+
49
+
50
+ def apply_move(state: str, move: str) -> str:
51
+ perm = QT[move[0]]
52
+ for _ in range(_TURNS[move[1:]]):
53
+ state = "".join(state[perm[i]] for i in range(54))
54
+ return state
55
+
56
+
57
+ def apply_sequence(state: str, moves) -> str:
58
+ for m in moves:
59
+ state = apply_move(state, m)
60
+ return state
61
+
62
+
63
+ class ValueNet(torch.nn.Module):
64
+ """A small Llama-shaped encoder over the 54 stickers, averaged, to one number."""
65
+
66
+ def __init__(self, hidden, layers, heads, vocab_size=28, max_positions=83):
67
+ super().__init__()
68
+ from transformers import LlamaConfig, LlamaModel
69
+ cfg = LlamaConfig(
70
+ vocab_size=vocab_size, hidden_size=hidden, intermediate_size=hidden * 4,
71
+ num_hidden_layers=layers, num_attention_heads=heads,
72
+ num_key_value_heads=heads, max_position_embeddings=max_positions,
73
+ pad_token_id=PAD,
74
+ )
75
+ self.encoder = LlamaModel(cfg)
76
+ self.head = torch.nn.Linear(hidden, 1)
77
+
78
+ def forward(self, ids):
79
+ h = self.encoder(input_ids=ids).last_hidden_state.mean(dim=1)
80
+ return self.head(h).squeeze(-1)
81
+
82
+
83
+ def load(repo_or_dir="briscoooe/tiny-cube-solver", device="cpu"):
84
+ """Loads the network from a local folder or from the Hugging Face Hub."""
85
+ import os
86
+ from safetensors.torch import load_file
87
+ if os.path.isdir(repo_or_dir):
88
+ cfg_path = os.path.join(repo_or_dir, "config.json")
89
+ weights = os.path.join(repo_or_dir, "model.safetensors")
90
+ else:
91
+ from huggingface_hub import hf_hub_download
92
+ cfg_path = hf_hub_download(repo_or_dir, "config.json")
93
+ weights = hf_hub_download(repo_or_dir, "model.safetensors")
94
+ cfg = json.load(open(cfg_path))
95
+ net = ValueNet(cfg["hidden"], cfg["layers"], cfg["heads"],
96
+ cfg["vocab_size"], cfg["max_positions"])
97
+ net.load_state_dict(load_file(weights))
98
+ return net.to(device).eval()
99
+
100
+
101
+ @torch.no_grad()
102
+ def distance(net, states, device, batch_size=1024):
103
+ """The network's guess of moves-to-solved for each state."""
104
+ out = []
105
+ for i in range(0, len(states), batch_size):
106
+ ids = torch.tensor([[BOS] + [COLOR_ID[c] for c in s] + [SEP]
107
+ for s in states[i:i + batch_size]], device=device)
108
+ out.extend(net(ids).float().clamp(min=0.0).tolist())
109
+ return out
110
+
111
+
112
+ def solve(net, state, device="cpu", beam=64, max_steps=26):
113
+ """Beam search over positions. Returns the solving moves, or None."""
114
+ if state == SOLVED:
115
+ return []
116
+ frontier = [(state, [])]
117
+ seen = {state}
118
+ for _ in range(max_steps):
119
+ children, paths = [], []
120
+ for s, path in frontier:
121
+ for mv in MOVES:
122
+ nxt = apply_move(s, mv)
123
+ if nxt in seen:
124
+ continue
125
+ if nxt == SOLVED:
126
+ return path + [mv]
127
+ seen.add(nxt)
128
+ children.append(nxt)
129
+ paths.append(path + [mv])
130
+ if not children:
131
+ return None
132
+ h = distance(net, children, device)
133
+ ranked = sorted(zip(h, children, paths), key=lambda x: x[0])
134
+ frontier = [(c, p) for _, c, p in ranked[:beam]]
135
+ return None
136
+
137
+
138
+ def main():
139
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
140
+ g = p.add_mutually_exclusive_group(required=True)
141
+ g.add_argument("--scramble", help='moves applied to a solved cube, e.g. "R U F\' D2"')
142
+ g.add_argument("--facelets", help="54-character cube state, kociemba layout")
143
+ p.add_argument("--beam", type=int, default=64)
144
+ p.add_argument("--model", default="briscoooe/tiny-cube-solver",
145
+ help="Hub repo id or local folder")
146
+ args = p.parse_args()
147
+
148
+ if args.scramble:
149
+ moves = args.scramble.split()
150
+ bad = [m for m in moves if m not in MOVES]
151
+ if bad:
152
+ sys.exit(f"unknown moves: {bad}; use {' '.join(MOVES)}")
153
+ state = apply_sequence(SOLVED, moves)
154
+ else:
155
+ state = args.facelets.strip()
156
+ if len(state) != 54 or set(state) - set("URFDLB") or any(state.count(c) != 9 for c in "URFDLB"):
157
+ sys.exit("facelets must be 54 characters, nine each of U R F D L B")
158
+
159
+ device = "cuda" if torch.cuda.is_available() else "cpu"
160
+ net = load(args.model, device)
161
+ t0 = time.time()
162
+ moves = solve(net, state, device, beam=args.beam)
163
+ secs = time.time() - t0
164
+ if moves is None or apply_sequence(state, moves) != SOLVED:
165
+ print(f"not solved (beam {args.beam}, {secs:.1f}s). Try a larger --beam.")
166
+ sys.exit(1)
167
+ print(" ".join(moves))
168
+ print(f"{len(moves)} moves, {secs:.1f}s on {device}, beam {args.beam}", file=sys.stderr)
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()