tiny-cube-solver: value network, solve.py and model card
Browse files- README.md +113 -0
- config.json +8 -0
- model.safetensors +3 -0
- 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()
|