Upload folder using huggingface_hub
Browse files- code/cube_ops.py +100 -0
- code/cube_tokenizer.py +93 -0
- code/eval_canonical.py +113 -0
- code/eval_value_search.py +100 -0
- code/fixtures/canonical_states.json +1 -0
- code/fixtures/prompt_pairs.json +74 -0
- code/gen_data.py +287 -0
- code/gen_rollout_data.py +152 -0
- code/run_benchmark.sh +68 -0
- code/search.py +89 -0
- code/serve.py +305 -0
- code/test_serve.py +48 -0
- code/train.py +253 -0
- code/train_value.py +216 -0
- code/train_value_iteration.py +307 -0
- code/vast_bootstrap.sh +250 -0
- code/verify_data.py +43 -0
code/cube_ops.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vectorized cube moves over numpy arrays of facelet indices.
|
| 2 |
+
|
| 3 |
+
`gen_data.apply_move` rebuilds a 54-character Python string per move, which is
|
| 4 |
+
fine when the bottleneck is a Kociemba call. Value iteration has no solver in
|
| 5 |
+
the loop: every training step expands all 18 children of every state in the
|
| 6 |
+
batch, so at batch 1024 that is ~18k move applications per step and the string
|
| 7 |
+
path becomes the whole cost.
|
| 8 |
+
|
| 9 |
+
A move is a fixed permutation of the 54 facelets -- `new[i] = old[perm[i]]` --
|
| 10 |
+
so a batch of states is one fancy-index away: `states[:, perm]`. Quarter turns
|
| 11 |
+
compose by indexing a permutation with itself (`p2 = p[p]`), which is how the
|
| 12 |
+
half- and counter-turns are built rather than by applying the quarter turn
|
| 13 |
+
repeatedly.
|
| 14 |
+
|
| 15 |
+
States are (N, 54) uint8 of colour indices into COLORS, not strings. Use
|
| 16 |
+
`encode`/`decode` at the boundaries only.
|
| 17 |
+
"""
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
from gen_data import QT, MOVES, SOLVED
|
| 21 |
+
|
| 22 |
+
COLORS = "URFDLB"
|
| 23 |
+
_C2I = {c: i for i, c in enumerate(COLORS)}
|
| 24 |
+
|
| 25 |
+
# MOVES order is (face, suffix) with suffix in ("", "'", "2") -- 1, 3 and 2
|
| 26 |
+
# quarter turns respectively. PERMS is indexed by position in MOVES so a move
|
| 27 |
+
# index from the model maps straight to a permutation.
|
| 28 |
+
_TURNS = {"": 1, "'": 3, "2": 2}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _compose(perm: np.ndarray, times: int) -> np.ndarray:
|
| 32 |
+
out = np.arange(54, dtype=np.int64)
|
| 33 |
+
for _ in range(times):
|
| 34 |
+
out = out[perm]
|
| 35 |
+
return out
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
PERMS = np.stack([_compose(np.array(QT[m[0]], dtype=np.int64), _TURNS[m[1:]])
|
| 39 |
+
for m in MOVES])
|
| 40 |
+
NUM_MOVES = len(MOVES)
|
| 41 |
+
assert PERMS.shape == (NUM_MOVES, 54)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def encode(states) -> np.ndarray:
|
| 45 |
+
"""Facelet string(s) -> (N, 54) uint8."""
|
| 46 |
+
if isinstance(states, str):
|
| 47 |
+
states = [states]
|
| 48 |
+
return np.array([[_C2I[c] for c in s] for s in states], dtype=np.uint8)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def decode(arr: np.ndarray):
|
| 52 |
+
"""(N, 54) uint8 -> list of facelet strings."""
|
| 53 |
+
return ["".join(COLORS[i] for i in row) for row in np.asarray(arr)]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
SOLVED_ARR = encode(SOLVED)[0]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def apply_moves(states: np.ndarray, move_ids) -> np.ndarray:
|
| 60 |
+
"""Applies one move per state. `move_ids` is an int array of length N."""
|
| 61 |
+
rows = np.arange(len(states))[:, None]
|
| 62 |
+
return states[rows, PERMS[np.asarray(move_ids)]]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def all_children(states: np.ndarray) -> np.ndarray:
|
| 66 |
+
"""(N, 54) -> (N, 18, 54): every state's 18 successors, in MOVES order."""
|
| 67 |
+
return states[:, PERMS]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def is_solved(states: np.ndarray) -> np.ndarray:
|
| 71 |
+
"""(N, 54) -> (N,) bool."""
|
| 72 |
+
return (states == SOLVED_ARR).all(axis=1)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def random_walk(n: int, max_depth: int, rng: np.random.Generator):
|
| 76 |
+
"""n states from independent walks off solved, depths uniform in 1..max_depth.
|
| 77 |
+
|
| 78 |
+
Returns (states, depths). The walk never turns the same face twice in a row,
|
| 79 |
+
matching gen_data.random_state -- consecutive turns of one face are a single
|
| 80 |
+
different move, so allowing them biases the effective depth downward.
|
| 81 |
+
|
| 82 |
+
Depths are returned for reporting only. They are an upper bound on the true
|
| 83 |
+
distance, never the label: value iteration derives its own targets, and a
|
| 84 |
+
walk of length 12 routinely lands somewhere far nearer to solved.
|
| 85 |
+
"""
|
| 86 |
+
depths = rng.integers(1, max_depth + 1, size=n)
|
| 87 |
+
states = np.repeat(SOLVED_ARR[None, :], n, axis=0)
|
| 88 |
+
prev_face = np.full(n, -1)
|
| 89 |
+
for step in range(max_depth):
|
| 90 |
+
live = np.where(depths > step)[0]
|
| 91 |
+
if not len(live):
|
| 92 |
+
break
|
| 93 |
+
mv = rng.integers(0, NUM_MOVES, size=len(live))
|
| 94 |
+
clash = (mv // 3) == prev_face[live]
|
| 95 |
+
while clash.any():
|
| 96 |
+
mv[clash] = rng.integers(0, NUM_MOVES, size=int(clash.sum()))
|
| 97 |
+
clash = (mv // 3) == prev_face[live]
|
| 98 |
+
states[live] = states[live][np.arange(len(live))[:, None], PERMS[mv]]
|
| 99 |
+
prev_face[live] = mv // 3
|
| 100 |
+
return states, depths
|
code/cube_tokenizer.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tokenizer for the cube task.
|
| 3 |
+
|
| 4 |
+
Deliberately not a text tokenizer. The task has a tiny closed vocabulary -- six
|
| 5 |
+
sticker colours in, eighteen moves out -- so every token is one symbol with one
|
| 6 |
+
meaning, and a sequence is a fixed 54-token state followed by a short solution.
|
| 7 |
+
A BPE tokenizer would only introduce ambiguity (multi-character merges, casing,
|
| 8 |
+
whitespace) into an input that has none.
|
| 9 |
+
|
| 10 |
+
Sequence layout:
|
| 11 |
+
|
| 12 |
+
[BOS] s0 s1 ... s53 [SEP] m0 m1 ... mk [EOS]
|
| 13 |
+
|
| 14 |
+
Loss is taken only on the move span and [EOS]. The state is the conditioning
|
| 15 |
+
prompt -- training the model to predict the scrambled state would spend capacity
|
| 16 |
+
learning the distribution of cube states, which is not the task and which the
|
| 17 |
+
model gets for free as input anyway.
|
| 18 |
+
|
| 19 |
+
Both the trainer and the serving shim import this module, so their vocabularies
|
| 20 |
+
cannot drift apart. A silent mismatch there would look exactly like a model that
|
| 21 |
+
failed to learn.
|
| 22 |
+
"""
|
| 23 |
+
from typing import List
|
| 24 |
+
|
| 25 |
+
COLORS = list("URFDLB")
|
| 26 |
+
MOVES = [f + s for f in "URFDLB" for s in ("", "'", "2")]
|
| 27 |
+
SPECIALS = ["<pad>", "<bos>", "<sep>", "<eos>"]
|
| 28 |
+
|
| 29 |
+
VOCAB = SPECIALS + COLORS + MOVES
|
| 30 |
+
STOI = {t: i for i, t in enumerate(VOCAB)}
|
| 31 |
+
ITOS = {i: t for t, i in STOI.items()}
|
| 32 |
+
|
| 33 |
+
PAD, BOS, SEP, EOS = (STOI[t] for t in SPECIALS)
|
| 34 |
+
VOCAB_SIZE = len(VOCAB)
|
| 35 |
+
|
| 36 |
+
# Colour token ids are contiguous, so a facelet's colour *index* (0..5, as used by
|
| 37 |
+
# cube_ops' array form) becomes a token id by adding this. Exposed here rather
|
| 38 |
+
# than as a literal in the array-based trainers, for the reason stated above: one
|
| 39 |
+
# definition of the encoding, so the two representations cannot drift apart.
|
| 40 |
+
# Colour token ids, indexed by the colour *index* (0..5) that cube_ops' array form
|
| 41 |
+
# uses. Exposed here rather than recomputed in the array-based trainers, for the
|
| 42 |
+
# reason stated above: one definition of the encoding, so the two representations
|
| 43 |
+
# cannot drift apart.
|
| 44 |
+
#
|
| 45 |
+
# These are NOT contiguous from len(SPECIALS), and the reason is a latent wart:
|
| 46 |
+
# MOVES contains the bare face turns "U", "R", ... which collide with the colour
|
| 47 |
+
# names, and STOI is built by dict comprehension, so the MOVES entries overwrite
|
| 48 |
+
# the COLORS ones. Consequences:
|
| 49 |
+
# - a colour and its bare-face move share one embedding (ids 10,13,16,19,22,25),
|
| 50 |
+
# - VOCAB_SIZE is 28 but only 22 ids are distinct, and 4..9 are never emitted.
|
| 51 |
+
# Harmless for a value net, which only ever reads state tokens, and a mild
|
| 52 |
+
# confound for the policy. Left as-is deliberately: every trained checkpoint
|
| 53 |
+
# encodes states this way, so "fixing" it silently invalidates all of them.
|
| 54 |
+
# Derive from STOI, never by arithmetic on len(SPECIALS).
|
| 55 |
+
COLOR_IDS = [STOI[c] for c in COLORS]
|
| 56 |
+
|
| 57 |
+
# 54 state tokens + BOS + SEP + a solution (Kociemba stays at or under 22 in the
|
| 58 |
+
# half-turn metric, God's Number being 20 for optimal solves) + EOS.
|
| 59 |
+
STATE_LEN = 54
|
| 60 |
+
MAX_SOLUTION = 26
|
| 61 |
+
MAX_SEQ = 1 + STATE_LEN + 1 + MAX_SOLUTION + 1
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def encode_state(state: str) -> List[int]:
|
| 65 |
+
"""[BOS] + 54 colour tokens + [SEP] -- the prompt the model conditions on."""
|
| 66 |
+
if len(state) != STATE_LEN:
|
| 67 |
+
raise ValueError(f"expected {STATE_LEN} facelets, got {len(state)}")
|
| 68 |
+
return [BOS] + [STOI[c] for c in state] + [SEP]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def encode_solution(moves: List[str]) -> List[int]:
|
| 72 |
+
return [STOI[m] for m in moves] + [EOS]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def decode_solution(ids: List[int]) -> List[str]:
|
| 76 |
+
"""Stops at [EOS]; ignores anything that is not a move token."""
|
| 77 |
+
out = []
|
| 78 |
+
for i in ids:
|
| 79 |
+
if i == EOS:
|
| 80 |
+
break
|
| 81 |
+
tok = ITOS.get(int(i))
|
| 82 |
+
if tok in STOI and tok in MOVES:
|
| 83 |
+
out.append(tok)
|
| 84 |
+
return out
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def encode_pair(state: str, moves: List[str]):
|
| 88 |
+
"""Returns (input_ids, labels) with labels masked to -100 over the prompt."""
|
| 89 |
+
prompt = encode_state(state)
|
| 90 |
+
answer = encode_solution(moves)
|
| 91 |
+
ids = prompt + answer
|
| 92 |
+
labels = [-100] * len(prompt) + answer
|
| 93 |
+
return ids, labels
|
code/eval_canonical.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluates the model on the harness's own canonical benchmark scrambles.
|
| 3 |
+
|
| 4 |
+
Separate from the in-training holdout on purpose. The holdout comes from the same
|
| 5 |
+
sampler as the training data, so it measures generalization to unseen states but
|
| 6 |
+
not robustness to a different generator. These states come from
|
| 7 |
+
`generateScrambleSet` -- a different code path, the one every LLM on the board was
|
| 8 |
+
actually scored against, at the standard seed = depth * 1000 -- so a gap between
|
| 9 |
+
the two numbers would say the model fitted its own sampler's distribution rather
|
| 10 |
+
than the task.
|
| 11 |
+
|
| 12 |
+
python eval_canonical.py --checkpoint checkpoints/cube
|
| 13 |
+
python eval_canonical.py --distribution-only # no model needed
|
| 14 |
+
"""
|
| 15 |
+
import argparse, json, sys
|
| 16 |
+
from collections import defaultdict
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
FIXTURE = Path(__file__).parent / "fixtures" / "canonical_states.json"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def distribution_report(states):
|
| 23 |
+
"""Kociemba solution length per depth bucket.
|
| 24 |
+
|
| 25 |
+
This is the God's Number plateau made visible: if scramble depth stops being a
|
| 26 |
+
difficulty axis past ~20 moves, then depth 25, 50 and 100 should all show the
|
| 27 |
+
same distance-to-solved as depth 20, and only the shallow buckets should look
|
| 28 |
+
different. It also shows whether the shallow buckets are genuinely easier, or
|
| 29 |
+
whether the scramble generator's redundant moves make a nominal depth-6 state
|
| 30 |
+
closer to solved than 6.
|
| 31 |
+
"""
|
| 32 |
+
import kociemba
|
| 33 |
+
|
| 34 |
+
by_depth = defaultdict(list)
|
| 35 |
+
for s in states:
|
| 36 |
+
by_depth[s["depth"]].append(len(kociemba.solve(s["facelets"]).split()))
|
| 37 |
+
|
| 38 |
+
print(f"{'depth':>6} {'n':>4} {'mean':>7} {'min':>4} {'max':>4}")
|
| 39 |
+
for depth in sorted(by_depth):
|
| 40 |
+
lens = by_depth[depth]
|
| 41 |
+
print(f"{depth:>6} {len(lens):>4} {sum(lens)/len(lens):>7.2f} {min(lens):>4} {max(lens):>4}")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main():
|
| 45 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 46 |
+
p.add_argument("--checkpoint", default="")
|
| 47 |
+
p.add_argument("--distribution-only", action="store_true")
|
| 48 |
+
p.add_argument("--limit-per-depth", type=int, default=0)
|
| 49 |
+
p.add_argument("--chunk", type=int, default=0,
|
| 50 |
+
help="Solve interactively: apply this many moves, then re-show the "
|
| 51 |
+
"model the true state and ask again. 0 = one-shot (default). "
|
| 52 |
+
"Interactive results are a different mode -- never table them "
|
| 53 |
+
"alongside one-shot scores.")
|
| 54 |
+
p.add_argument("--max-rounds", type=int, default=12)
|
| 55 |
+
p.add_argument("--beam", type=int, default=0,
|
| 56 |
+
help="Beam-search the model's distribution, verified by the engine. "
|
| 57 |
+
"0 = greedy. Searches only what the model already ranks highly; "
|
| 58 |
+
"it adds no independent distance estimate.")
|
| 59 |
+
args = p.parse_args()
|
| 60 |
+
|
| 61 |
+
states = json.loads(FIXTURE.read_text())
|
| 62 |
+
if args.limit_per_depth:
|
| 63 |
+
seen = defaultdict(int)
|
| 64 |
+
keep = []
|
| 65 |
+
for s in states:
|
| 66 |
+
if seen[s["depth"]] < args.limit_per_depth:
|
| 67 |
+
keep.append(s)
|
| 68 |
+
seen[s["depth"]] += 1
|
| 69 |
+
states = keep
|
| 70 |
+
|
| 71 |
+
if args.distribution_only:
|
| 72 |
+
distribution_report(states)
|
| 73 |
+
return
|
| 74 |
+
|
| 75 |
+
if not args.checkpoint:
|
| 76 |
+
p.error("--checkpoint is required unless --distribution-only")
|
| 77 |
+
|
| 78 |
+
import torch
|
| 79 |
+
from transformers import LlamaForCausalLM
|
| 80 |
+
from gen_data import SOLVED, apply_sequence
|
| 81 |
+
from serve import solve, solve_beam, solve_chunked
|
| 82 |
+
|
| 83 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 84 |
+
model = LlamaForCausalLM.from_pretrained(args.checkpoint).to(device).eval()
|
| 85 |
+
|
| 86 |
+
mode = (f"chunked (chunk={args.chunk}, max_rounds={args.max_rounds})" if args.chunk
|
| 87 |
+
else f"beam-{args.beam}" if args.beam else "one-shot")
|
| 88 |
+
print(f"mode: {mode}")
|
| 89 |
+
by_depth = defaultdict(lambda: [0, 0, 0])
|
| 90 |
+
for s in states:
|
| 91 |
+
if args.chunk:
|
| 92 |
+
moves = solve_chunked(model, s["facelets"], device,
|
| 93 |
+
chunk=args.chunk, max_rounds=args.max_rounds)
|
| 94 |
+
elif args.beam:
|
| 95 |
+
moves = solve_beam(model, s["facelets"], device, beam=args.beam)
|
| 96 |
+
else:
|
| 97 |
+
moves = solve(model, s["facelets"], device).split()
|
| 98 |
+
ok = bool(moves) and apply_sequence(s["facelets"], moves) == SOLVED
|
| 99 |
+
by_depth[s["depth"]][0] += ok
|
| 100 |
+
by_depth[s["depth"]][1] += 1
|
| 101 |
+
by_depth[s["depth"]][2] += len(moves)
|
| 102 |
+
|
| 103 |
+
print(f"{'depth':>6} {'solved':>8} {'n':>4} {'rate':>7} {'avg moves':>10}")
|
| 104 |
+
total_ok = total_n = 0
|
| 105 |
+
for depth in sorted(by_depth):
|
| 106 |
+
ok, n, mv = by_depth[depth]
|
| 107 |
+
total_ok, total_n = total_ok + ok, total_n + n
|
| 108 |
+
print(f"{depth:>6} {ok:>8} {n:>4} {ok/n:>6.1%} {mv/n:>10.1f}")
|
| 109 |
+
print(f"{'ALL':>6} {total_ok:>8} {total_n:>4} {total_ok/total_n:>6.1%}")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
main()
|
code/eval_value_search.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Scores the value function + state search on the canonical benchmark scrambles.
|
| 3 |
+
|
| 4 |
+
Separate from eval_canonical.py because this is a different system: that one
|
| 5 |
+
decodes a policy (greedy, beam, or chunked), this one searches the state space
|
| 6 |
+
with a learned distance estimate as the heuristic and the engine as the
|
| 7 |
+
transition function. The policy is not involved at all.
|
| 8 |
+
|
| 9 |
+
Run on a GPU. Each solve evaluates `beam * 18` child states per step for up to
|
| 10 |
+
`--max-depth` steps -- tens of thousands of network calls, which is nothing on a
|
| 11 |
+
GPU and hopeless on a few CPU cores.
|
| 12 |
+
|
| 13 |
+
python eval_value_search.py --value <path-or-hub> --beam 64 --limit-per-depth 25
|
| 14 |
+
"""
|
| 15 |
+
import argparse, json, os, sys, time
|
| 16 |
+
from collections import defaultdict
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 20 |
+
from gen_data import SOLVED, apply_sequence
|
| 21 |
+
|
| 22 |
+
FIXTURE = Path(__file__).parent / "fixtures" / "canonical_states.json"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main():
|
| 26 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 27 |
+
p.add_argument("--value", required=True,
|
| 28 |
+
help="Path to value.pt, or a Hub repo id to download it from.")
|
| 29 |
+
p.add_argument("--beam", default="64",
|
| 30 |
+
help="Beam width, or a comma-separated list to sweep. A sweep "
|
| 31 |
+
"reuses one box for every width, which matters because "
|
| 32 |
+
"the deep buckets are where width decides the outcome and "
|
| 33 |
+
"a single guess at it wastes the whole run.")
|
| 34 |
+
p.add_argument("--lam", default="0.0",
|
| 35 |
+
help="Weight on path length: states are ranked by h + lam*g. "
|
| 36 |
+
"0 is pure greedy on the heuristic (fastest, and longest "
|
| 37 |
+
"solutions), 1.0 is standard A*. Accepts a comma-separated "
|
| 38 |
+
"list to sweep.")
|
| 39 |
+
p.add_argument("--max-depth", type=int, default=26)
|
| 40 |
+
p.add_argument("--limit-per-depth", type=int, default=25)
|
| 41 |
+
p.add_argument("--depths", default="")
|
| 42 |
+
args = p.parse_args()
|
| 43 |
+
|
| 44 |
+
import torch
|
| 45 |
+
from search import load_value_model, solve_value_beam
|
| 46 |
+
|
| 47 |
+
path = args.value
|
| 48 |
+
if not os.path.exists(path):
|
| 49 |
+
from huggingface_hub import hf_hub_download
|
| 50 |
+
path = hf_hub_download(args.value, "value.pt", token=os.environ.get("HF_TOKEN"))
|
| 51 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 52 |
+
model = load_value_model(path, device)
|
| 53 |
+
print(f"value model on {device} | beam {args.beam} | lam {args.lam}", flush=True)
|
| 54 |
+
|
| 55 |
+
states = json.loads(FIXTURE.read_text())
|
| 56 |
+
if args.depths:
|
| 57 |
+
keep = {int(d) for d in args.depths.split(",")}
|
| 58 |
+
states = [s for s in states if s["depth"] in keep]
|
| 59 |
+
if args.limit_per_depth:
|
| 60 |
+
seen = defaultdict(int)
|
| 61 |
+
kept = []
|
| 62 |
+
for s in states:
|
| 63 |
+
if seen[s["depth"]] < args.limit_per_depth:
|
| 64 |
+
kept.append(s)
|
| 65 |
+
seen[s["depth"]] += 1
|
| 66 |
+
states = kept
|
| 67 |
+
|
| 68 |
+
grid = [(b, l) for b in [int(x) for x in str(args.beam).split(",")]
|
| 69 |
+
for l in [float(x) for x in str(args.lam).split(",")]]
|
| 70 |
+
for beam, lam in grid:
|
| 71 |
+
print(f"\n=== beam {beam} lam {lam} ===", flush=True)
|
| 72 |
+
by_depth = defaultdict(lambda: [0, 0, 0, 0.0])
|
| 73 |
+
print(f"{'depth':>6} {'solved':>8} {'n':>4} {'rate':>7} {'moves':>7} {'s/solve':>8}",
|
| 74 |
+
flush=True)
|
| 75 |
+
for s_ in states:
|
| 76 |
+
t0 = time.time()
|
| 77 |
+
moves = solve_value_beam(model, s_["facelets"], device, beam=beam,
|
| 78 |
+
max_depth=args.max_depth, lam=lam)
|
| 79 |
+
ok = bool(moves) and apply_sequence(s_["facelets"], moves) == SOLVED
|
| 80 |
+
b = by_depth[s_["depth"]]
|
| 81 |
+
b[0] += ok
|
| 82 |
+
b[1] += 1
|
| 83 |
+
b[2] += len(moves) if ok else 0
|
| 84 |
+
b[3] += time.time() - t0
|
| 85 |
+
# Printed per depth as each depth's last scramble lands, rather than
|
| 86 |
+
# only at the end: a wide beam on the deep buckets can run for a long
|
| 87 |
+
# time, and a box that is killed or times out mid-sweep should still
|
| 88 |
+
# have reported every bucket it did finish.
|
| 89 |
+
if b[1] == sum(1 for x in states if x["depth"] == s_["depth"]):
|
| 90 |
+
ok_, n_, mv_, secs_ = b
|
| 91 |
+
print(f"{s_['depth']:>6} {ok_:>8} {n_:>4} {ok_/n_:>6.1%} "
|
| 92 |
+
f"{mv_/max(ok_,1):>7.1f} {secs_/n_:>8.1f}", flush=True)
|
| 93 |
+
|
| 94 |
+
tot_ok = sum(v[0] for v in by_depth.values())
|
| 95 |
+
tot_n = sum(v[1] for v in by_depth.values())
|
| 96 |
+
print(f"{'ALL':>6} {tot_ok:>8} {tot_n:>4} {tot_ok/tot_n:>6.1%}", flush=True)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
main()
|
code/fixtures/canonical_states.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[{"depth":3,"index":0,"facelets":"BUUFUUFLLDLLRRURRUDBBDFFRFFBDDBDDFRRULLRLLRDDFFLBBUBBU"},{"depth":3,"index":1,"facelets":"BRRLUULUUBBDURDURDURRFFFFFFRRRDDDFLLUFFULDLLDBBLBBLBBD"},{"depth":3,"index":2,"facelets":"UDBUUBUUBLLLRRRRRRFFDFFDFFDDDBDDBDUBRLLRLLRLLUBFUBFUBF"},{"depth":3,"index":3,"facelets":"FRRUUUUUULLUDRRDFFBBBFFLFFLLLBDDDDDDDRRLLUBBUFFLBBBRRR"},{"depth":3,"index":4,"facelets":"RRDBUBBUBDDFRRRRRRUFRUFUUFUFDFFDFLLFULLULLBLLLBBDBBDDD"},{"depth":3,"index":5,"facelets":"UUDUUDUUDRRRRRRLLLBBBBFBBFBUDDUDDUDDRRRLLLLLLFFFFBFFBF"},{"depth":3,"index":6,"facelets":"UUUUUULRRDLLDRRLLUBFFBFFBBBDDDDDDLLRRRULLUDRRFFFBBBBFF"},{"depth":3,"index":7,"facelets":"BBRUUFUUFRRBRRBRRDRRDUFDUFDFDBFDBFDLUFFLLLLLLULLUBDBBD"},{"depth":3,"index":8,"facelets":"UURUUUBBURRDRRBRRBRRFFFFFFFDDDDDDDDLFFULLLLLLBLLUBBUBB"},{"depth":3,"index":9,"facelets":"UUUUUULLRDLLRRULLUBBFFFFBBBDDDDDDLRRRRUDLLDRRFFFBBFBBF"},{"depth":3,"index":10,"facelets":"DDFUUFLLLURRURRFLLFFFFFFUDDLRRDDBUUBRLDRLDRLBUBBUBBDBB"},{"depth":3,"index":11,"facelets":"DUUDUUDBBDBBRRRRRRBRRBFULFUUDFUDFBDFLLLLLLUFFLLFDBFDBR"},{"depth":3,"index":12,"facelets":"RRRUUBUUBRRFRRDFFUFFUFFUULLLDDLDDLFFBLLULLBBBDDDBBBRRD"},{"depth":3,"index":13,"facelets":"LLFUUFUUBDRRLRRLUUFFRFFDBBDRRFDDBDDBDLLDLLRRUUBBUBBLFF"},{"depth":3,"index":14,"facelets":"UUUUUUFRRDRRDRRDBBLFFRFFRFFBLLUDDUDDLLULLDFFDBBBBBBLLR"},{"depth":3,"index":15,"facelets":"RUUBUULLLBRRURRUFFUUULFFLFFFRRFDDLDDBLFBLDBLDBBDBBDRRD"},{"depth":3,"index":16,"facelets":"BUUBUURLLBRRDRRDRRDUUFFFFFFLLLFDDFDDLLFLLURRUBBDBBDBBU"},{"depth":3,"index":17,"facelets":"BDBBUUUUURRLRRLRRLBFFBFFBFFDDDFDDFUFLLRLLRLLRUBDUBDUBD"},{"depth":3,"index":18,"facelets":"LLLUUURDDFRULRULBBURRFFFFFFUUUDDRDDRDLBDLRFFRBBBBBBDLL"},{"depth":3,"index":19,"facelets":"LLLDUUDUURRDRRUDLLBFFBFFBBBRRRDDUDDUULLDLLRRUFFFBBBBFF"},{"depth":3,"index":20,"facelets":"RLDUUDUURULLURRURRFFBFFBBBBDDLDDULRUDLLDLLDRRBBFFBFFBF"},{"depth":3,"index":21,"facelets":"BBBLUULUUBBDURRURRURRUFFLFFFRRFDDFDDUFFLLLDDDLLLBBDBBR"},{"depth":3,"index":22,"facelets":"DDDUURUURFFLDRLDRBLLUFFFFFFLLLDDDRUURBBRLUFLUBBBBBBRRD"},{"depth":3,"index":23,"facelets":"DDLRUURUUBBFDRRDRRDRRBFFBFFULLUDDRDDBFFULLULLULLBBFBBF"},{"depth":3,"index":24,"facelets":"FFLUULUULUUURRURRURRFDFFDFFBDDBDDRRRDFFDLLDLLBLLBBBBBB"},{"depth":3,"index":25,"facelets":"UDUUUUUUULRRLRRLRRFFBFFBFFBDDDDDDDUDRLLRLLRLLBBFBBFBBF"},{"depth":3,"index":26,"facelets":"UUBUUFUUFRRLRRLUBBBBDFFDRRLBBFDDDDDDRRRLLLFFDUFFUBBLLL"},{"depth":3,"index":27,"facelets":"DDDDUUUUULLRRRLRRLFBBFFBFFBUUUDDUDDDRRLLLRLLRFFBFBBFBB"},{"depth":3,"index":28,"facelets":"FRRFUUFUULLDRRDRRDLBBUFFUFFBDDFDDFLLUUULLRLLRBBRBBDBBD"},{"depth":3,"index":29,"facelets":"FLLUUDUUDRRURRUFFDFFBFFBLLLDDDDDDBRRULLULLUBBBBRBBRFFR"},{"depth":3,"index":30,"facelets":"UUUUUUUUBDRRDRRRBBFFRFFRFFUDDFRDDRDDLLLLLLFFLBBBBBBLLD"},{"depth":3,"index":31,"facelets":"UUFUUFUUURDDRRRFRRFFFFFFDDLRRDDDBDDBLLLLLLLLBRBBUBBUBB"},{"depth":3,"index":32,"facelets":"UUFUUFLLFRRRRRRUUDFFDFFDBBLRRFDDFDDBLLDLLDLLUUBBUBBRBB"},{"depth":3,"index":33,"facelets":"RUUBUUBDDBRRLRRLFFULLUFFDFFLUUFDDFDDBLLBLLRRFBBDBBDRRU"},{"depth":3,"index":34,"facelets":"UUUUUUDDDRFFRRRFFFBBBBFFBLLUDDUDDUDDBBLLLLLLLRRRBBFRRF"},{"depth":3,"index":35,"facelets":"DUUDUULRRDRRDRRLLUFFFBFFBBBUDDUDDRLLRRDULLULLBBBBBFFFF"},{"depth":3,"index":36,"facelets":"DDDUUUBLLURLURLUFFRFFLFFLFFFRRUDDUDDRLURLDBBDBBBBBBRRL"},{"depth":3,"index":37,"facelets":"UURUUFRRRBRBDRBDRBDDDRFFRFFFLLDDBDDLFFFLLULLUULLUBBUBB"},{"depth":3,"index":38,"facelets":"UUDUUDLLRURRURRDLLFFBFFBFBBLRRDDUDDURRDLLDLLUFFFFBBBBB"},{"depth":3,"index":39,"facelets":"UUUUUUBFFDBBRRRRRRRLLDFFDFFBDDBDDFDDFFURLLRLLLLLBBUBBU"},{"depth":3,"index":40,"facelets":"LLLUUUUUULLDLRDLRDBBBFFFFFFUUUDDDRRRDRRDLRDLRFBBFBBFBB"},{"depth":3,"index":41,"facelets":"DDLUULUULFFFURRDRRLLDFFFBBBRRRDDUDDUFBBLLDLLUURRFBBFBB"},{"depth":3,"index":42,"facelets":"DRBDUULUURRDRRDBBDBFFBFFBRRRDDUDDULFFFULLULLULBRLBFLBF"},{"depth":3,"index":43,"facelets":"RRDUUFUUFRRBRRBFFBFFDFFDLLLDDDDDDLLUFLLULLUBBRUURBBRBB"},{"depth":3,"index":44,"facelets":"DDLDUUDLLURDURDURDBFFBFFBFFURRUDDUURRLLRLLRLLFBBFBBFBB"},{"depth":3,"index":45,"facelets":"RUDDUDDLLURRURRDRRBFFBFFBBBURRUDULDUULLDLLDLLFBBFBFFBF"},{"depth":3,"index":46,"facelets":"FUUFUUFLRULLURRURRRFBDFBDFBBRLBDDFDDDDDRLLRLLFFLBBUBBU"},{"depth":3,"index":47,"facelets":"UUUDUULLLURRURRURRFFFFFFBBBLLLUDDDDDRLDRLDRLDBBFBBFBBF"},{"depth":3,"index":48,"facelets":"LLFUUFUULFRRFRRFUUFFDFFDDLDRDRRDBRDBDLLDLLBBBUBBUBBLRU"},{"depth":3,"index":49,"facelets":"DUUDUUDUURRRRRRBRRLFFBFFLLLUUUDDDDDDFFFLLLLLFBBRBBFBBB"},{"depth":6,"index":0,"facelets":"UUFBUFLFRDLLURRBBBURFUFBUUDRRLBDLBDLBDFLLFRRFDLRDBDUFD"},{"depth":6,"index":1,"facelets":"BUDUUDUUFLBBRRRRRBRRUFFBFFFDDDDDDLLULFFULLULLRBDFBLRBB"},{"depth":6,"index":2,"facelets":"BBFUUFLLBLURLRRBBDFFDFFDRRRUDDUDDFRRULDRLDUBBULLBBUFFL"},{"depth":6,"index":3,"facelets":"RDDFUURUUBBFRRULDDDRRDFFLBFBRULDBLDBFLFFLFBBULLULBURRD"},{"depth":6,"index":4,"facelets":"BUUBUFLURBRFBRLLRLULDUFDRRUDDBDDBUUDLLFLLFRFFRRDFBDFBB"},{"depth":6,"index":5,"facelets":"FFURUULDDLBRLRUBBUUFFUFFFRRRDDBDDBRBUUFDLFDLDBRRLBLLBL"},{"depth":6,"index":6,"facelets":"BDDBUULRRULRURRULLFBBBFFDFFRRRUDFBDFLLDLLULRBFFDDBDUBU"},{"depth":6,"index":7,"facelets":"UURUUFRFDLLUDRRBRRBUFBFFUDDLLLBDFUUFFRDRLDRLBFLLDBBDBB"},{"depth":6,"index":8,"facelets":"FLLUURURRBUFRRFLLUBDDLFFLBBFDDBDDDDBUBRLLURRDUFRUBBLFF"},{"depth":6,"index":9,"facelets":"FDDUUUUFBRRLLRLBFDRLUUFDUFLFDUBDUDDBDBFRLLRRLBBLBBFRRF"},{"depth":6,"index":10,"facelets":"DDFFUBURRFLLURLRRUBUUFFBLFFBDDBDDBLLBURLLRRRUDBLDBUFFD"},{"depth":6,"index":11,"facelets":"FLRUUDDDRBRUURUULFBBDDFBUFBBDLBDFDLLLRLRLLRRRFUUFBFDBF"},{"depth":6,"index":12,"facelets":"BUUDUUDUUBBBFRRFBDBRRBFUURURDLBDDRFFDLLFLLFRFLLRFBDLLD"},{"depth":6,"index":13,"facelets":"UFFUULDDDLURLRDLLDBFFBFFRDFURUBDDBRRLBLULLRRBUUBBBRFFD"},{"depth":6,"index":14,"facelets":"BDDFUDFLFLFLURLDRRDDULFBLFRBLFBDDBUURRLRLURRUBBUBBUFFD"},{"depth":6,"index":15,"facelets":"BDDBUULLLUFBDRRRLLFFFFFLDDUBBFBDURUURRDBLDFLLRRUFBUBRD"},{"depth":6,"index":16,"facelets":"LDDFUUBUURRRRRLBBFDFFDFFULLBUUBDDLUUFLRFLRBRRFLDBBDLBD"},{"depth":6,"index":17,"facelets":"DRRDUUDLLULUURRBBBBFFBFFRFLBDUUDUFDULLLDLLDRDFFFBBRRBR"},{"depth":6,"index":18,"facelets":"RDRUUBUUBRRFRRURRLFFUFFUURULDFLDFLBBBLLDLLDBBDLDBBFDDF"},{"depth":6,"index":19,"facelets":"DUUDUFLLFRRRRRRDUFBBDFFUBBLDDBDDFLLRLLULLDFRRBBFBBFUUU"},{"depth":6,"index":20,"facelets":"UUDUUDUBULRRBRRBFDRDBLFRLDRULDDDUBBFBBFLLFDFFFRRFBULLL"},{"depth":6,"index":21,"facelets":"BDDBUUBDBLRRLRRBFDDFDBFDLLLUUUUDUUDFRRRLLLFBFFBUFBFLRR"},{"depth":6,"index":22,"facelets":"DDLUULLLBLUULRFFBRFFDFFDURRFUURDDRFBFBDLLDDBLBRRUBBURB"},{"depth":6,"index":23,"facelets":"LBDUUFUUFRRBRRBLLLRRDFFDFFFDDUDDUULUDFFDLLRLLRUBRBBBBB"},{"depth":6,"index":24,"facelets":"LULUULRDDBFFRRRRRRULLBFFFFFUDDUDDLDDBFBULLDLRURUBBBBBF"},{"depth":6,"index":25,"facelets":"UDDUUDUUDLLBLRRLLLBBFFFBRRBDDDUDUUDUFRRRLLFFFRBLBBFBFR"},{"depth":6,"index":26,"facelets":"RRRDUURRRURULRDLRLBBBBFFBFFUUUUDDDDDDLDULLLLLFFFBBBFFB"},{"depth":6,"index":27,"facelets":"FLFUUUUDUFFDLRULRULRLFFBFFBDDDDDURRRULBDLRDLLRFRBBBBBB"},{"depth":6,"index":28,"facelets":"DUUDUUDDDFBBLRRLBRRRRBFBRLBUUDDDDUUULLBLLRLFBLRFFBFFFF"},{"depth":6,"index":29,"facelets":"LLFUUFRDDFRRLRRLUUURRDFFLBDURFUDBBDBDLBDLLRFFUBBUBBLFD"},{"depth":6,"index":30,"facelets":"UUULUUBBDLFFURRUBRUUFRFLDFLBRFFDLRDDBDLFLDFLLRRRBBDBBD"},{"depth":6,"index":31,"facelets":"DDDDUDLURFBLRRLFBBFRUFFBFFURRLLDUUUURRDULDBFDBLBBBFRLL"},{"depth":6,"index":32,"facelets":"FRUBURBUFLFFDRDBUDDBUUFFDLLLUUDDFRLLRRRLLRBLFRDDBBFBBU"},{"depth":6,"index":33,"facelets":"ULRBUUFUULLDDRLDFFDBBBFRUURRRFDDUBFUBRLLLDDFFBFRDBBLRL"},{"depth":6,"index":34,"facelets":"LLLUUBUUDRRDRRDRLFBBBDFFUBFFDDBDFLLRBRRULRBLLFUUFBFUDD"},{"depth":6,"index":35,"facelets":"FRFBUUDDLBFUURRBRRFFULFRUUDBBLFDDFDDULRLLULLRLFRBBDBBD"},{"depth":6,"index":36,"facelets":"DRBDUBDRLUDDLRRBLLRUFFFBFFUUURFDUFUULLBLLRDDRLBFDBFBBR"},{"depth":6,"index":37,"facelets":"DRRDUURBBRRDDRDUFFDUUFFFBBLLLFLDRLDRLLFLLUUUDBBFBBFURB"},{"depth":6,"index":38,"facelets":"DDUUUFFLLUDBRRRRRRDUFBFFBBFRLDUDDUDDBRLLLUFFULLLBBFBBR"},{"depth":6,"index":39,"facelets":"UDLUUFUDDFRFLRFBLLBRRDFFDLLLUUBDBBDBRRRBLFRRFUBFUBUDLD"},{"depth":6,"index":40,"facelets":"RDFFUBFUBRRUBRUBRLDRUDFLBFLDDUDDFDUBFLLFLLRRRLBULBUDBF"},{"depth":6,"index":41,"facelets":"UUUUUBRRBDDRRRRDLUDDRFFUFFLDDBFDDFLLLLFLLURRLBBBBBBFFU"},{"depth":6,"index":42,"facelets":"RRRLUULDDLRDURDFLUUFFLFFBBDLLRDDDRRBUFFULUURDBBBBBBLFF"},{"depth":6,"index":43,"facelets":"FDFLUULUUBBRRRLFFLBRRDFBDFDBURBDRBDDLUURLLRLLUFUFBDFBD"},{"depth":6,"index":44,"facelets":"DUUDUFFRLBDFLRRURRRUUBFDBLLRUFFDDLLDBBDULLDRURBLBBFBFF"},{"depth":6,"index":45,"facelets":"UULUULRLLUDDFRRBRRFFFBFDBBULRRUDFDUFLLULLDBFDFBBDBBDRR"},{"depth":6,"index":46,"facelets":"RRFDUFDDDBLLBRRDDLFFLFFLULBRURDDRUUUULRULRLBFDBBUBBBFF"},{"depth":6,"index":47,"facelets":"BLUBURLLRFFBDRRDRRUUUFFBLLBFFRLDUBUFLUFDLDUBDLDDBBRDFR"},{"depth":6,"index":48,"facelets":"FLLDULRRRDUUBRBDUBFFFFFUURFBDLLDRBRRLFUDLUDFRBBUDBLDBL"},{"depth":6,"index":49,"facelets":"RUDFUFRLBUDFBRRFRRDDLRFDRBUBLLFDBLUBFLFFLDBRDLBUUBUULD"},{"depth":10,"index":0,"facelets":"UUBUUBBRDBLLLRDFBDRBLFFUFRUDFLDDUBDRRRURLLRLLUFFFBDFBD"},{"depth":10,"index":1,"facelets":"DDFFUDUBBRLDBRLUFUFUUFFLDUFBLRUDDDDBRRLRLUFRLRBBFBBLRL"},{"depth":10,"index":2,"facelets":"BFLBUDRRDFLFLRDDDDFURBFULFLFUBFDBUUBRRULLLBDDURURBFRBL"},{"depth":10,"index":3,"facelets":"UUBUUDDFFDFRDRRBRLFLLBFRBBDUDLUDFFFFBBRLLLRLLDRRBBDUUU"},{"depth":10,"index":4,"facelets":"BUBBUBUFRFDDDRFLLUBRULFRRUDBRFDDFRLLUURRLUFLDLFLDBBFBD"},{"depth":10,"index":5,"facelets":"RBDUULUUUFFRFRRUDDBRLDFRDUBBFLBDFRDFFLRBLLDLLFRUDBULBB"},{"depth":10,"index":6,"facelets":"BBLFULFFUFUURRFDBDRRLDFDFFLUDBUDUDDFRLDBLLBRRBLUUBRLBR"},{"depth":10,"index":7,"facelets":"BFFBUDURDBLURRFDDDRBLDFUDFBLURDDRRLRUUFULFBBFLLLRBLFBU"},{"depth":10,"index":8,"facelets":"DLLFUURUURLUDRUFFDBFFUFBUFUBDLBDLFDLFRDLLRLRRBBRBBDBRD"},{"depth":10,"index":9,"facelets":"RUFRURLRRDBRDRUBDUUFFUFFBUULRRFDBFLBBDFLLFLLDUBDLBDLBD"},{"depth":10,"index":10,"facelets":"RLLBUULUURRUFRDURDFFFFFDFLLRFFDDDDLLURDULRBLDBUBBBBBBR"},{"depth":10,"index":11,"facelets":"BBLRUUFDLUBUDRFDRUDBFFFLFBLRLBUDDUUFDFLULLRRDBRRDBFRLB"},{"depth":10,"index":12,"facelets":"LRFDUUULBDLLURDDDLRBRRFBRBRUDFLDFLUBBLFULFFFBDBURBFDRU"},{"depth":10,"index":13,"facelets":"DDUFUFUDUFURRRRFRRRLLFFDDULBLDRDFLLFRDFULLUBLBBBUBBDBB"},{"depth":10,"index":14,"facelets":"RURDUUUBLFFBURBULBFRDBFBFFBRDLUDFBDRFRLFLLDRDULUDBRDLL"},{"depth":10,"index":15,"facelets":"FURBUFDDBDRBLRFFFLLRRUFUULULDLBDLRBBULFFLBFRBURRUBDDDD"},{"depth":10,"index":16,"facelets":"LBBFUBLDBURRLRUFLRDRLUFUFBRLLUFDFRDBFUBFLRFRUDDDBBDULD"},{"depth":10,"index":17,"facelets":"LDFDUFBUDFUULRLBRBLBRBFBULRRDDUDFBLUFFDBLRLRFLRDUBDRFU"},{"depth":10,"index":18,"facelets":"RFDBUBFLDFUBDRFLDDLURBFFFBUDDBRDLUULFLURLRRDLRLURBUBFB"},{"depth":10,"index":19,"facelets":"DDDDURRRBLDFURFUDBFFDFFLFUBLRLLDFUURFLURLURBULBRLBBDBB"},{"depth":10,"index":20,"facelets":"BBFBUFFDULRRBRRUFBRBBDFRDLLRFFDDDDLULLDULLFRBUUDUBFRUL"},{"depth":10,"index":21,"facelets":"FUURUUFURUBBDRLLBDDLBRFLRRBDBDLDDFDBLULRLFRBFLFUFBDRFU"},{"depth":10,"index":22,"facelets":"BFDRUDFBURLRFRRDBFDLFRFRUFLLLBDDURUURBLDLUDBBFUUDBFLLB"},{"depth":10,"index":23,"facelets":"BLLDUBDLLULBRRUUFFRFFBFULFFFRRFDDUDDUBBULRRUDDDLBBLRRB"},{"depth":10,"index":24,"facelets":"DBFRUFRDLFUDLRBULFBFDFFURLRFDBUDBUFUBDDRLLBBURRLDBULRL"},{"depth":10,"index":25,"facelets":"BLRRUDUUURBFRRLLRLFFFDFFRLBUDDUDBFFUDULULRLBBDBRFBLBDD"},{"depth":10,"index":26,"facelets":"BBDRUDULBLLFDRRURBBFDBFFRBLDDFUDFRURUURULRUFFLLLDBBDLF"},{"depth":10,"index":27,"facelets":"UDRFULRDDBUFURLDFBUFLRFRLLFBBLLDURURFRBRLBUDUDBLFBDDBF"},{"depth":10,"index":28,"facelets":"RRLUUDFLBUFDURLBBUUBLRFLBRDDFLFDDFDLUBRDLUDURFBBFBRFLR"},{"depth":10,"index":29,"facelets":"LUFFUUDLRFRULRRBBFFBUUFFBRRLDDLDDUFLBURDLLRDDLBUBBFDRB"},{"depth":10,"index":30,"facelets":"DRLFUUBBRDRULRDUFULDFRFFDBRLLBUDRRDLRUDULDULFBBBFBBFLF"},{"depth":10,"index":31,"facelets":"UFLDURDFDRUFLRRLRRBUBBFDFFDUDFUDFLLFBBLULRULRULRDBBDBB"},{"depth":10,"index":32,"facelets":"BUBRUBFURBLLBRRRFDLBDDFDFFURLFBDDLFLRUUFLLDRDULUDBUBRF"},{"depth":10,"index":33,"facelets":"DBBDUDUFFULLLRRBURBRRDFFDRRLDDBDLUUFBFRRLBLLFUULUBBDFF"},{"depth":10,"index":34,"facelets":"UUUUUFFFLFRLRRUUUBRLDFFBBBFDDRBDRLLLBBDRLDBLRFLRFBDUDD"},{"depth":10,"index":35,"facelets":"RBBFUBFRLDDLLRBDDDLFBDFFFDRURFUDFUBFBUUULLRRRUUDLBLLRB"},{"depth":10,"index":36,"facelets":"DBDDUBLLLULRURRFRRDDFBFFBBLURDUDURDBRRBFLDULLFUBFBLUFF"},{"depth":10,"index":37,"facelets":"URLFUBRUBLLFBRUDRFBLDBFUBFFUDLFDDBRDRRDLLDULLUUFFBDRBR"},{"depth":10,"index":38,"facelets":"BLDUUDBDRFFFURUUBBRRUBFRBFLLRFUDRFDRUFULLDDBDLFLLBBDLR"},{"depth":10,"index":39,"facelets":"FFUUUDLLRFLBRRBRRDFFUUFBURBFFUDDUDDLRLDULFBBLLDDLBBBRR"},{"depth":10,"index":40,"facelets":"BRDLUDRBURLLDRRLUUDLFBFBLRFFUUFDFRLRUUFFLUDDDBBLDBRBFB"},{"depth":10,"index":41,"facelets":"FLRFUFFUFULDDRDBRBRBRRFFLBDUDLUDBLUUDRDLLDUFFBBLLBURRB"},{"depth":10,"index":42,"facelets":"LURRURDBBRFFDRLDDLLUUFFFLLBUURDDRULBBBFBLUFBFDRUDBLDFR"},{"depth":10,"index":43,"facelets":"LRRBUFLDLDLULRUFBFBBBLFUULDBBRFDUBULURURLDRDRFFFFBDDRD"},{"depth":10,"index":44,"facelets":"RDUUUUDDUFRRFRRDDBRRLFFLLLRDBFDDFDULFBBLLRFBBBLUBBUUFL"},{"depth":10,"index":45,"facelets":"ULUDURDUBDURRRLBFLFLRDFBFFLDDUBDLUUBRRRRLBLULBDFBBFDFF"},{"depth":10,"index":46,"facelets":"LDBFUDUURULRDRLLFLLRBLFRRBFDDUBDUULDDRBRLUFUFDFBFBBFBR"},{"depth":10,"index":47,"facelets":"BBRUUBDLRURDURFRRDBUBFFBULUFDFFDFRRFURLBLUFLLBLLDBDLDD"},{"depth":10,"index":48,"facelets":"LDRFUDLLBRLDRRFURUUUUBFDDFFLDRLDBRRBDRFULLFFFBBBUBBLUD"},{"depth":10,"index":49,"facelets":"DUDBUBRRRDLBDRFURFUFFLFFUFLLLFDDDLURLRBLLDBBBRBFUBUURD"},{"depth":15,"index":0,"facelets":"UDRUULULDFUDFRRUBLFFRBFRDBFLURUDLDLUBRLDLRLFFBBRDBFBDB"},{"depth":15,"index":1,"facelets":"LDLDUUBRDRFDFRLRULLUBRFLFRFUFDBDBRLUURDFLBBDRFLFUBDBBU"},{"depth":15,"index":2,"facelets":"URDBUDRRBULLURURFDUFLDFLRBFBUDFDLFUFRLBRLBUDDBBFRBDLFL"},{"depth":15,"index":3,"facelets":"ULLUUDBBFDLFURDLBDRRLFFBFRURDBUDDRLLRRULLRDFDUUFFBFBBB"},{"depth":15,"index":4,"facelets":"BDFUURUBBRBDDRFFRLRUUFFLDUUBLLLDUBBDUFFDLRRBLRFLLBRFDD"},{"depth":15,"index":5,"facelets":"RRLFULRUDFUURRDFFFUBRUFBLLLDDDFDDFFRBRBLLRUUBBDDBBBULL"},{"depth":15,"index":6,"facelets":"DRFDUDDUFRLULRURRURBDFFBLUUFLFFDUBRBBBBFLLUDDLDLFBRLBR"},{"depth":15,"index":7,"facelets":"LUBFUBFBBULURRULDRDDLRFDRRFFFURDLBFFDULFLURBURLBBBLDDD"},{"depth":15,"index":8,"facelets":"FDLDURBRBDDUBRFFLLRFRUFULBLURDLDBRLBUFUULLFDFBBRUBRDFD"},{"depth":15,"index":9,"facelets":"BDBBUUDLLBRURRFFFLBBUUFDFFDUDRBDLFRFDDLLLBLRRRLRUBUUFD"},{"depth":15,"index":10,"facelets":"RDRFULDLBUUDRRLRLFBFLUFFUUFBRDRDDUDLFULDLBLBRBRUBBBDFF"},{"depth":15,"index":11,"facelets":"BDUDUFLRUBLFURFUUBDDRFFBFDLRFFLDRLLLDLBRLUDBDRBRRBBUUF"},{"depth":15,"index":12,"facelets":"LUUFUBFUFRRLRRDDLRDLDFFDUUBLFRFDBRBBDRLLLLUDBFRBBBDUUF"},{"depth":15,"index":13,"facelets":"FLBRUUURFRLRBRBLFUBUDRFRFLFUFUFDDLDBDDRBLFBURDBLDBULLD"},{"depth":15,"index":14,"facelets":"UBFUULBFBRBLRRURDBLDUDFBRFFFUDRDLLRRLRDFLBFFUDUBLBLDDU"},{"depth":15,"index":15,"facelets":"RULUURFDBDDUBRDUFLDBRFFLURLBBFRDDFBBDLLFLRRURBFFLBLDUU"},{"depth":15,"index":16,"facelets":"BRBUUBULUBLDLRUDDFRDRBFFUBRLRFFDFLDUDFFLLUDRBLDRRBULBF"},{"depth":15,"index":17,"facelets":"FDBFUDRLUFBURRRDBRBULBFULFLFLBDDULBFUUDDLRUFDRRRFBLDLB"},{"depth":15,"index":18,"facelets":"BDUDUURUDLBRLRLBFBFFFBFFFLLLDUUDRFUDDRUDLRDLUBBRBBFLRR"},{"depth":15,"index":19,"facelets":"FDUUUBBBLFRRURBBUFLLDRFRBDRULDFDLUDDLFDLLDFRLBFUUBFRBR"},{"depth":15,"index":20,"facelets":"LLFLUUBLDFBRFRRLULDDRDFRUBBFDDUDRUBUFFRBLFRFLUUDDBLBRB"},{"depth":15,"index":21,"facelets":"RRBBUFRDDFLLLRDFUUBFRUFBUBLRUDDDFBULUDDRLRDLFUFBRBBFLL"},{"depth":15,"index":22,"facelets":"BFDDULBLDLFRRRUDBBUBFRFBUUBRLRBDDFDDRLLFLUUUFFDUFBRLRL"},{"depth":15,"index":23,"facelets":"FLBDUDLBFDFDDRLUBRBRLUFLDBLFUFFDDDUBURULLRBRRLFRUBBUFR"},{"depth":15,"index":24,"facelets":"DDLUURLBLUFULRBLUBFLFUFFULBBDDUDLUDRFRDRLBFFRBRRDBBDFR"},{"depth":15,"index":25,"facelets":"FFLFUBBBLFDBLRRRLLRRDUFFULDFBBRDDRUURDURLBUULDUDFBDBLF"},{"depth":15,"index":26,"facelets":"UDDRUFFBDLRBBRULRLRRFLFDFUUULBFDULUFBDDFLBBLRRLRBBDUFD"},{"depth":15,"index":27,"facelets":"RDDBUFUUURUBBRBFFDLRFLFUBLDLDRFDRFLFUDBFLBULDRRBRBDLUL"},{"depth":15,"index":28,"facelets":"LFFUURLRRBFDBRUFLDBBDBFDFLRLDUFDUBBLFRUDLUUDURLDFBRBLR"},{"depth":15,"index":29,"facelets":"BURDUFLLLULDURBFFBDDFUFBDUDLRRDDRFLUUBBFLLRRFBFLRBDRBU"},{"depth":15,"index":30,"facelets":"LDRUUFDLURLDRRBBLLLUFFFDBLLRBURDDDUFDRFFLRRBUBFBDBUUBF"},{"depth":15,"index":31,"facelets":"BUDLUDFFURBLURBRLUDDFLFFDBFRRDFDUUDBRDLRLFLRBBBULBULRF"},{"depth":15,"index":32,"facelets":"DDFBURLFRFULFRBULFBDULFRBDLRRFFDDLUDRRUBLUBLUDBBUBLRFD"},{"depth":15,"index":33,"facelets":"LFRBULLDDRDFBRUUBRDFBUFLURLBFFFDUFBUBRBDLLLLRDUURBRFDD"},{"depth":15,"index":34,"facelets":"URLBUDRLLURBURBFLFBFFLFBURDLURUDDURLBLDDLUFFBDBRDBFDFR"},{"depth":15,"index":35,"facelets":"DRUUUUFFULRRRRLFLULLBFFBLDDDBRLDDLDFRBURLUDBBBFBUBDRFF"},{"depth":15,"index":36,"facelets":"BLDDUFBBBRDRLRFFRRUUUBFDURLFUDUDFDLUDRLRLDLFLFBRLBBFUB"},{"depth":15,"index":37,"facelets":"FULBUDLULULURRDDBUFBFLFUDLLRUBRDDUFFRRDDLBRFBBFDFBRRLB"},{"depth":15,"index":38,"facelets":"BDRBULDDBRBUURLDRFRRUUFBBUFULLUDFRDULDBRLFFRLFFDFBBLLD"},{"depth":15,"index":39,"facelets":"LDRFULRDFRUDLRFFUBULDUFFFBRDRUDDRULDUDBFLBBRLBBFRBULBL"},{"depth":15,"index":40,"facelets":"LFFLUUULURLLDRBLUBBFFLFRDBFFUUDDRBFRBBRULDDBRDDURBFDRL"},{"depth":15,"index":41,"facelets":"UDRDUBURRURDURFDFLFDBRFBLDLFBBLDUDFURFLLLURUDBLFLBBBRF"},{"depth":15,"index":42,"facelets":"RLLUULUDBUBBFRRLBFRFLDFRRFFUUUUDDBDLDRFULLRBBDFFBBLDRD"},{"depth":15,"index":43,"facelets":"RBDRUDBLLULFURUUBBDFFFFFRFFURRDDLBBLDDRLLDDBBLRFRBUUUL"},{"depth":15,"index":44,"facelets":"RDFFULBBFDBUDRRDFBUULLFLFLRUFFRDDLDUBULRLUBBRLBDUBFRRD"},{"depth":15,"index":45,"facelets":"URBFURFRRFBDLRBLBDLUUBFDBUUDLBRDLRUFBLUDLUFDRLFRDBFLFD"},{"depth":15,"index":46,"facelets":"RFBLUBRURBDDLRRURLDBDDFFBLBRBLBDUFDDFUFDLRURULUUFBLFFL"},{"depth":15,"index":47,"facelets":"RUUUUUUUULBLRRBDDDBFBRFFDFBBLRDDBUFFDRRDLBFRLFLFLBLLDR"},{"depth":15,"index":48,"facelets":"DLFRUDUBFDRUURFBFDBLLDFBRBLFRUDDUBLLFFRRLBRFULDRLBUBUD"},{"depth":15,"index":49,"facelets":"LURLURRFFDDULRFRDBUULBFFBBDLRBUDFDULUBBDLDRRDFBFRBLULF"},{"depth":20,"index":0,"facelets":"LDBBURDFRFBLDRRRFRBRUFFLBLDRBBUDDUUFFULULLLLUUBDDBFDRF"},{"depth":20,"index":1,"facelets":"LDULUFBDRBLFRRRFBULBDRFFUDDLRRUDUFDRFBDULBUFBRLDUBLBFL"},{"depth":20,"index":2,"facelets":"FDBFURRLFLDDLRDBRDFBUFFUFBUDURLDULBBRLUFLUUDLLBDFBRRRB"},{"depth":20,"index":3,"facelets":"RFBUULBUFDFUDRBBRBLFLUFLFDRRFDLDBFRLFRDULLUBDRRUDBBUDL"},{"depth":20,"index":4,"facelets":"DLFFUUFRFDFDRRUBRULFLUFUUBDRRLBDDUFRRLULLBBDFRBBLBDBDL"},{"depth":20,"index":5,"facelets":"DBBRUURUFLFLRRFDBUBLURFFRBLUUBBDRRDFLDDLLUFDBULFDBFRLD"},{"depth":20,"index":6,"facelets":"UDLBUBRUDRDFBRFLUDDFBDFLUFURLBUDRDLFBRFBLFLLFURRRBULDB"},{"depth":20,"index":7,"facelets":"URBBUFLDULLUDRBBRFDLBLFFFDRUBDUDFDRDFRBULBFLRRDLUBFRUL"},{"depth":20,"index":8,"facelets":"FUUBUDFDBURRLRDDULLFLUFDBBBLRRRDLFRDRLUFLFRFDBBDBBLFUU"},{"depth":20,"index":9,"facelets":"BLRRUBLRLDRFDRURFLUFBBFBUFULDFUDUBUDRDFFLLRLBDDURBLFBD"},{"depth":20,"index":10,"facelets":"FLBLUURLFLBRDRBUUFFFUFFFBUBRRLBDFDDLRDUDLRLLUDUDRBBDRB"},{"depth":20,"index":11,"facelets":"UBDLURLRDRBRFRURDBFDBBFDLBUUDFLDLDRULUDFLULFFFLBRBURFB"},{"depth":20,"index":12,"facelets":"UDDLUDFLBUBBRRBDBURFLDFFLULFLBUDUFUFBBDFLLURDRRRRBDRFL"},{"depth":20,"index":13,"facelets":"URUDUFURFDUBLRUDDRRULBFUFBLRLBBDFBLDFLFRLDURDLFLBBDBFR"},{"depth":20,"index":14,"facelets":"RBFDURBLDBFULRRFFRUDLFFFULLRBDRDDULFBBLRLURDFLUDUBBDUB"},{"depth":20,"index":15,"facelets":"UDRUUBRLDRLBRRBLRDDFBFFDDDFLLUFDBBFLRLFRLULRFUBFUBUBDU"},{"depth":20,"index":16,"facelets":"UULFUBLLUBRULRBFBDFFRLFUBRUDULDDDDDRRRDULDLFRBFFLBBFRB"},{"depth":20,"index":17,"facelets":"BDFBUBUFUBLRURLFBBFLRDFRLRLBFDFDDDDRLRLBLLRUUUFDUBUDRF"},{"depth":20,"index":18,"facelets":"DBBDURFUBUDULRLRBBLLLRFFLUDFFBDDURFDFFURLUULDRDRBBBLRF"},{"depth":20,"index":19,"facelets":"LRLRURFFUBFBBRLFFULDRRFURDDFBRUDLBUBFBUBLDRLUDUDDBLLFD"},{"depth":20,"index":20,"facelets":"BBDFUFLRURRFDRUDBBDDFLFFLFRULFUDLLURRUBDLDULFLRUBBBDRB"},{"depth":20,"index":21,"facelets":"LFLDUDFUBLLFLRLRRURFDBFBLUDBLBUDBBDFFRDFLUURUURDFBDRBR"},{"depth":20,"index":22,"facelets":"BBFFUDUFFLLLFRDDBUBRURFDRLRBUFBDLURFLLRULDBRDDUDBBFRUL"},{"depth":20,"index":23,"facelets":"LBFBUFDDDBLURRBBRFRLLLFUDFUFDRLDFUDLBRBULUFBRLDUUBFDRR"},{"depth":20,"index":24,"facelets":"FBFLUDDDDLLUFRURBBRFFDFRBBULUFUDRRLLRUBLLRBFDLDDRBFUBU"},{"depth":20,"index":25,"facelets":"UBDFUURRRFBLDRRRLLUUUBFFRBFBDDLDDDFFLLBRLLFUDBRBFBDUUL"},{"depth":20,"index":26,"facelets":"FURRUBRLFDUDDRFFLLUFLRFBLFRURULDDDLURUBRLBLBFBFDDBDBUB"},{"depth":20,"index":27,"facelets":"UFFFUBUDLFRULRFFURBLDUFBDDDBBRDDLRBULRRLLRDRLLDBUBFFUB"},{"depth":20,"index":28,"facelets":"FDFRUFLDFUDDURLUDBFLRBFRRURBLBUDRDFLLFDLLRLBDRBUBBFUUB"},{"depth":20,"index":29,"facelets":"LLDRUBRDRDUFLRBBBDFFFFFUUUDFFLDDLURBBUURLLRRLLDUDBBRFB"},{"depth":20,"index":30,"facelets":"FLDBUDFRBUFLLRRUUDUULLFUUBRFUBRDFDDFRDRLLDBFLBFDBBBLRR"},{"depth":20,"index":31,"facelets":"BUDRUDBFLFBBFRUUDLLLDFFDFBFLRRLDLDLURUDRLRRBURBUFBDBUF"},{"depth":20,"index":32,"facelets":"LLRFUFURBLDUURLUBRBFDDFRBDLURFFDDRUDFURRLLFLLFUDBBBBBD"},{"depth":20,"index":33,"facelets":"RRLBURBUUBFULRDFLRLRRBFUFULLFDDDFDDFFUDDLLBFUBBULBBDRR"},{"depth":20,"index":34,"facelets":"FFFRUDBUBDLURRDBRDLLRLFFLFLFUUUDURBRUDDLLFBBDLDRBBBFRU"},{"depth":20,"index":35,"facelets":"DFUFULUDDRUBURFLBLBFBDFBRBDFDFRDLBLFFURULRDBULRRLBRUDL"},{"depth":20,"index":36,"facelets":"LUFFUUUDBDBDDRBLBRLLRDFRLLFFBURDDRUBDRBFLFUUDRFBRBLULF"},{"depth":20,"index":37,"facelets":"FRLUUDBFLBBDURULFLDRULFRFUBRLDBDDURFUBRLLDRLDFDRFBFUBB"},{"depth":20,"index":38,"facelets":"DFDRUBUFBRUFLRRULRRLUDFBLFBUDLBDUBRDFFFBLLDDFLURUBRBDL"},{"depth":20,"index":39,"facelets":"URBBUUBDDFRLURFFDDDLRLFBFRUUDLFDBLFLBLRFLUDRRUBRUBLBDF"},{"depth":20,"index":40,"facelets":"BFFFUULFBRFLRRLUDUDLULFBUULRBFDDRFRBDRBBLDDBFDDRUBLLUR"},{"depth":20,"index":41,"facelets":"FRDDUDLUULBBRRFRFRDLBFFBUUFFBDUDLUBBDLBULRFFLRDLDBRULR"},{"depth":20,"index":42,"facelets":"LDBUUULUDRLLLRRDDUDFBBFFBRRRUFFDLLDFFBBBLLFRUUFDDBRRBU"},{"depth":20,"index":43,"facelets":"UULFUBBDFLUULRBRFDLBUUFUBBDRRBRDRDLRRDDFLFFDUBRFLBLFDL"},{"depth":20,"index":44,"facelets":"RUUDUFFFULRRBRLDFBLLBBFDFRLUUBLDDLFRDRULLRDBRBBFDBUDUF"},{"depth":20,"index":45,"facelets":"URUUUDBLDFFFRRDUUFRBRLFFBUBLLLDDRRBLFFUULFDRDRBLLBBDDB"},{"depth":20,"index":46,"facelets":"RBBLUDUDFUBUURUUFLLRRLFLFFLDDFFDLBUBDBBULDRRLRRFBBRDFD"},{"depth":20,"index":47,"facelets":"LUDBUUBLUBBFBRLBRRDURFFLUUDFRLRDFFDFBDRLLDRBLLFUDBFDRU"},{"depth":20,"index":48,"facelets":"LDRUUDUDBDLBRRLBUFLFRUFFFBDRDLRDLRLLUFBBLBUUDURFFBRDBF"},{"depth":20,"index":49,"facelets":"RRURUDFURURBBRLRFFLFBLFLDFFLDDBDRDFRBUUBLDLUFLBDUBDULB"},{"depth":25,"index":0,"facelets":"LLRBUFRRFLDBFRDLBLBDURFUDLDFBFRDDUUBBRDLLFFURUFULBUDBR"},{"depth":25,"index":1,"facelets":"FUDFUDUUBRLRLRRRRFRRUUFFBDDUBBDDDBFLLUFLLBDFLFLUBBBDRL"},{"depth":25,"index":2,"facelets":"LURFUFUUDLLFURDBRRFFFLFLRFLFRUDDBUBDDDLBLBRLUDRBRBUBDB"},{"depth":25,"index":3,"facelets":"DUURULLLDLDBRRFULBDFFBFBULRFUBFDBRURFUBRLDUDLLBRRBDDFF"},{"depth":25,"index":4,"facelets":"RLDDUFUDBRRLURLDRBRLUUFLLDRFBFUDDUULBFFBLBLRDBFDBBRUFF"},{"depth":25,"index":5,"facelets":"RDUDULUBUBFFBRDRUDLURFFRDLFBDDBDLLUFBFBRLUFLLRRDBBFLRU"},{"depth":25,"index":6,"facelets":"BBFLULRFURFDRRBLDUBUFRFFFDUDFBLDLRDLLUDULDBBLRUDRBRFBU"},{"depth":25,"index":7,"facelets":"BBRLUFBRURUDURLDDBLFFBFRFLRDFFBDFFUUUDDRLRUDLBLLUBDRBL"},{"depth":25,"index":8,"facelets":"DRDBUDUDUBFFBRULUBFLRUFRDBUBDBUDLFRRFLLFLFRRLLFRBBLDDU"},{"depth":25,"index":9,"facelets":"FBDUUUUURBFRURDFRBBBDDFLBDRURULDBUFDDRRBLFLFLFLLLBDLRF"},{"depth":25,"index":10,"facelets":"UDFBULLDDRFRBRFDLDBFBDFDBRLRUBUDBLURFRUFLRDBUULLUBRFLF"},{"depth":25,"index":11,"facelets":"LLRBULBDLFBFURLRDFRBDRFRBFDLUBBDLUFUBRUDLFFUDDFUUBRLDR"},{"depth":25,"index":12,"facelets":"LBBUUFULFRDULRRRLRFDDBFBFRUDDFUDUBLDBRLBLDDFLRUUFBRBFL"},{"depth":25,"index":13,"facelets":"LURUUFLBLUDDDRDBULBRFDFRRRUDFRLDBRBDDRUULLUFFBFBBBLFLF"},{"depth":25,"index":14,"facelets":"RDRFUUBDLFRBLRBDRBLBDLFFFLRLUFLDFUDRFUDBLDBBUUFUUBRDRL"},{"depth":25,"index":15,"facelets":"FDFRUFLDLDLUFRBRURFRBFFDUBBLDUUDLUBDRBDFLRFRBLLDUBUBLR"},{"depth":25,"index":16,"facelets":"UDDLURUFDRBRFRDLULRLBFFULUFFRULDLBUUBBFRLRDDDFBRFBDBBL"},{"depth":25,"index":17,"facelets":"UURLUBLBURUDDRFFLLDDFRFLFUDDLRFDBBRUFFBRLFUULBRLDBBBDR"},{"depth":25,"index":18,"facelets":"RRFRUBBFLFURLRRDUBUDDLFDFLLLUBBDFDRUBBLLLBRDUUFDUBFRDF"},{"depth":25,"index":19,"facelets":"RLBBULLLFUDRRRRFDDUFRBFFBULUFDDDBLDRUUFLLRBFLDBBUBUFRD"},{"depth":25,"index":20,"facelets":"UDRUUDDFFLBULRRFLDLRULFBLFDBURDDUBRBBBFLLDDFUFRRBBFRUL"},{"depth":25,"index":21,"facelets":"FLRFUDDLBRRURRLUDDFDURFUDUBLBLDDBLLBLRRFLBBFFFBUFBURUD"},{"depth":25,"index":22,"facelets":"RLDLUUFFURRFRRUFBBRDFLFBBRUDFLRDDLLDUBDULFUDRLDBBBFLUB"},{"depth":25,"index":23,"facelets":"LFRLUBDBRBUBRRDUDFLRDFFFLRFBURUDLFUUDBFDLLDFUUDBBBRLLR"},{"depth":25,"index":24,"facelets":"UUDUURLBUFBLFRDUFDFDLRFLBDRUFBLDUBBRRRDULFRDLBLFRBBFLD"},{"depth":25,"index":25,"facelets":"URUUURFLBLUFDRDLRDDDDFFFURUFFBBDDRLRBLLLLUDULRBRBBBFFB"},{"depth":25,"index":26,"facelets":"RLDFUDUFULBFBRRBRURRBFFUBLDDFLLDDUDLDDFRLURBRLUFUBBFLB"},{"depth":25,"index":27,"facelets":"LBRFUFFLRBRBDRLDDBRDDUFRLBLULBUDBFFLFDDULBRFFURDUBRULU"},{"depth":25,"index":28,"facelets":"UURDUFDLFLRFDRRURRBBUUFFULFLFRDDBLFDBBLDLLDRBDBRUBLBUF"},{"depth":25,"index":29,"facelets":"BURUUURDLURBRRLUURDFFLFFFDBDRLDDBDRUDLFLLBLBLUFRDBFFBB"},{"depth":25,"index":30,"facelets":"RLURUDRURFBLLRFLURBBUFFBFFBDDDRDLLRFUBDFLLUULFDBRBUDDB"},{"depth":25,"index":31,"facelets":"UUUBUURRLFBBFRURDFBBDDFLFLBRDUFDRBDRFLDFLBDRDLRLLBUUFL"},{"depth":25,"index":32,"facelets":"FBDDURDRURDLFRUBLBLBFLFRLRUBURUDFDBRLFFFLDRLUBLUBBUDDF"},{"depth":25,"index":33,"facelets":"RBRRUDDUURLFLRBUBLBFFBFFFFBDRLUDRDDFUULFLLBLLDDBUBDURR"},{"depth":25,"index":34,"facelets":"RLFBUURLDLBUDRRDLDFBFFFRDUBFFRBDFLFLUDULLDURRLUBUBDBRB"},{"depth":25,"index":35,"facelets":"DLLFUUFDLDBUFRUFBUDFBDFRRLDUURBDDUFLRULRLLFRBBBBRBDFLR"},{"depth":25,"index":36,"facelets":"RUDLUBBBRBRFBRRFBLLLDFFDFFURULFDURRUUDDULLUDDLRBFBLBDF"},{"depth":25,"index":37,"facelets":"DDRUULRRRFBBFRBFRBDBURFLBULULDBDFUFRBRFDLDLDLUFLUBLDUF"},{"depth":25,"index":38,"facelets":"UBDRULBRDLUBURLFBLUFFUFFBDDRFRBDULRBRDLFLRFDURLFDBLDBU"},{"depth":25,"index":39,"facelets":"RDBLUULRDFBRDRLUUUFBRRFFULLLBFBDFDUFUDDRLFLDBDRBFBURLB"},{"depth":25,"index":40,"facelets":"BDDFUBRBRDDFFRLBUFULFLFUBDDUFLRDBURRDRBLLDLBLLRRFBUUUF"},{"depth":25,"index":41,"facelets":"RLRFUULFLBRFDRLUDUFRUDFFDDFBLRFDRUBLBLDRLBRULDUDBBBFUB"},{"depth":25,"index":42,"facelets":"BRUUUDFRRFLLURFBLLRUUFFBRLLUUULDBDRDLFDDLDBFBFDDRBBFBR"},{"depth":25,"index":43,"facelets":"RLLLUDDDBURFRRLFRRBBLUFULUDFBRFDBFBBBFLRLFRDDUUDDBFULU"},{"depth":25,"index":44,"facelets":"BLRBURDLRFUFRRBRULBFUUFFULBLDUUDLUFDDDLDLBLFBDBRRBRFDF"},{"depth":25,"index":45,"facelets":"DBBUULLFBUUUURRRDLDLLFFRRLFFBDFDLLFFRBBRLRDDURDBBBDUUF"},{"depth":25,"index":46,"facelets":"FRRDURUDLUBFBRLDURBLFLFDDRFBDLUDFLRDUFRFLBUBLDFRUBLBUB"},{"depth":25,"index":47,"facelets":"FDDBUULFLDFBFRDRDFUDBLFLDRBLFUUDRBBRRLFULULBFRBDLBRURU"},{"depth":25,"index":48,"facelets":"RLBUURBUUFDLURFDDBLRLLFLLFRFRFDDFBLRFBDBLBUBDUFUUBRDDR"},{"depth":25,"index":49,"facelets":"RLFDURURLUDLBRBBURLUFLFDFBDRRLRDFBBUUFBFLDRFDDUBUBLFLD"},{"depth":30,"index":0,"facelets":"BULBURDBURBBLRLDBULLFFFFUDRBRFFDULFLDDFULDURRDLRDBRFUB"},{"depth":30,"index":1,"facelets":"UFRUUUDFFRRDLRRUDULUDDFUDFRBLBBDFUBBRBFDLLLLLBRFDBBLRF"},{"depth":30,"index":2,"facelets":"RLRDUBLRRUUFDRUDLUFDBDFBBBLULBUDFDRLFLDBLFBRLDUUFBRFFR"},{"depth":30,"index":3,"facelets":"LBDDUFRRBUURRRDFBDBBLLFFBRRRUULDLFULFFDDLDUFUFUDRBBBLL"},{"depth":30,"index":4,"facelets":"FRFFULLDLFBRFRULURDBDLFDDLFRFUFDLLRBRUBULDURBUBDBBRUDB"},{"depth":30,"index":5,"facelets":"URRRUURLRUFFFRLLRUBFBBFRFBBUUDUDDFLBFUDFLDLLRDBLBBDLDD"},{"depth":30,"index":6,"facelets":"RRBBURFLDRUUBRDFLLDUBLFRFBRLDUFDFLBUDLLRLDBUURDFFBFBUD"},{"depth":30,"index":7,"facelets":"BRRBUFRUUBDDDRFFFLDLRBFLFRLUFDUDLURBULFDLRLBRBULUBBDDF"},{"depth":30,"index":8,"facelets":"UBDDUUDRFDBBFRRULDBBLRFLUDFFFRRDBULRLLLULDRFLRDBUBFFUB"},{"depth":30,"index":9,"facelets":"UUURUDLDBLFRFRFFDLFLDLFUBBRDUUDDRFBUFBDLLUDBRBRLRBFBLR"},{"depth":30,"index":10,"facelets":"BFBRUDUDLDFRLRLDUBFLBBFFUBRRRFUDFLRLRDLULDDLFDRUBBBUUF"},{"depth":30,"index":11,"facelets":"UUFDULDRDBDURRLDFRRBLFFFBBFRDLLDLRFULRBBLUFBULRBUBUFDD"},{"depth":30,"index":12,"facelets":"ULURUDURDBFLBRLFLRBULRFRDBDRDRUDBDLULFRFLDFBBFDBFBUFUL"},{"depth":30,"index":13,"facelets":"UFRRUFBUBDLURRFRLLUBRLFFLBFURDDDUDDBBDLRLBFLFFDRUBUDBL"},{"depth":30,"index":14,"facelets":"BLLLUURBBULBRRFFDLDULDFBUUURRLFDRBDDRDFFLBRLFDBURBUFFD"},{"depth":30,"index":15,"facelets":"LFRDUUDLRDRUDRUUFULBFUFRRLRBDBLDLBRLDBFFLBLUDFRBFBDFBU"},{"depth":30,"index":16,"facelets":"FRDFULDBULDRRRLRFLRRBUFUFUBDFUDDDULBURBLLBLBLFDRBBFDUF"},{"depth":30,"index":17,"facelets":"FDFRUFLDUBURDRRDLDBBRFFLLBRULFUDUBUBDBULLRDBFUFLDBFRRL"},{"depth":30,"index":18,"facelets":"DDFBULRBBRFULRDDURDUUFFDUUFLLLBDRURDBRFDLUFLBLFLRBBBFR"},{"depth":30,"index":19,"facelets":"LLLUULRRLFFBDRFRBDUUDBFRLUDBBBFDRFURUFBDLDRRUDBFDBLFLU"},{"depth":30,"index":20,"facelets":"RUDBUBURDBLFDRBULRBFLRFFLUFULRBDDUFFBRRDLUBDFLFDUBRDLL"},{"depth":30,"index":21,"facelets":"DBUBUULRURLRDRRURDBUFFFBBDLLFFDDBDRBFUUULLFLDBLRFBFRDL"},{"depth":30,"index":22,"facelets":"LFRRUDFBBDFBURDBLDULRFFBFFLRRULDDDBLUURBLLFUDUUFRBDBRL"},{"depth":30,"index":23,"facelets":"DDFBUUURRFLDDRFDLRFUUBFFUBFLLLBDDRRDBULULRBDBRRLLBFBFU"},{"depth":30,"index":24,"facelets":"RBFRUFLFDRLLLRUUDRDUBLFUBDLRFFFDRUBUDBBLLDBRUDUFRBBFDL"},{"depth":30,"index":25,"facelets":"FUULUURFULBRFRRRRDFLBFFDDDFBRDBDULLBLBUBLUDDLBLUFBRRDF"},{"depth":30,"index":26,"facelets":"RDLFULLBFUBDRRBDURURRUFFBDLUFBDDLBUFBLFDLRUBLFRDUBLDFR"},{"depth":30,"index":27,"facelets":"LFBDUDRUBDRUURLLUFFBRRFFFFURRBBDLDRUFBULLBLLDRDDFBDLUB"},{"depth":30,"index":28,"facelets":"LDFRUUULBRFDLRUFRDRDUBFFUDLLRDBDFLLBDUFRLLFDBRFBBBBRUU"},{"depth":30,"index":29,"facelets":"DBFLULLDLFUDDRRRRUDLDFFBBDBURUFDBUFFRBBFLULLLRUBUBRRDF"},{"depth":30,"index":30,"facelets":"RDUBUDRRULFLURLDDLDBBUFLBDBLBRFDRURDFLFBLRRUDFLUFBUFFB"},{"depth":30,"index":31,"facelets":"LDBRURFLLDDLLRRFBDDFBDFUFBDURRFDUULBUULBLLRURUFFFBDRBB"},{"depth":30,"index":32,"facelets":"LRULUUUUUBLFLRFRUFLFRLFFDBFLDDBDBDUUDDBRLBBRFRDBDBFLRR"},{"depth":30,"index":33,"facelets":"LDDUUURLDBBRBRRLRUBFLBFLRDDULFFDDUFFBLDBLDLUBFFUUBRRRF"},{"depth":30,"index":34,"facelets":"LRRBUDLUDBLUFRRLLUFLLRFRRFUDUBFDBRBRUDDLLBDDFFDFUBFBUB"},{"depth":30,"index":35,"facelets":"BRFRUUFFBULDBRRFLULDLDFURFLULDFDBBDFDUUFLLDUBRBRDBRRBL"},{"depth":30,"index":36,"facelets":"DUFLUDLBURLDFRLBBBURFBFRLUUFRRFDLBUDRFFRLDLDDRBBUBDLFU"},{"depth":30,"index":37,"facelets":"ULDUUDLDFLLLRRBRDDFFULFFRFBFLUUDBDFRLRDRLUBBUBBBRBDFUR"},{"depth":30,"index":38,"facelets":"RLDFUDLURDFBLRBLBLBRFUFDUBDRDFDDRDRFUUUFLBLRFRUBLBLUFB"},{"depth":30,"index":39,"facelets":"LRUBUDDFRDBLURUULLRUFLFRLBRFRBDDDFLUDUBFLFRRDFFBLBDBBU"},{"depth":30,"index":40,"facelets":"FFRBULRUBLUDDRFURFURDDFRDUBLBLDDBFDDLLBFLBRLFBUURBLRFU"},{"depth":30,"index":41,"facelets":"UURLUFLBLDRFRRLUDFFUBLFBRLRBBBDDRUURFDDBLUBFDDRLFBDUFL"},{"depth":30,"index":42,"facelets":"DLLFUUUBBDBDURBDBBLRRFFLFFLUUBRDDLDUFRBRLLFURFDRLBDRFU"},{"depth":30,"index":43,"facelets":"LFFRUURFURLDDRBFFLBRFUFRLUUDFLDDDBBDBBDULBULBRLULBRFDR"},{"depth":30,"index":44,"facelets":"BRLLULFUDLDFBRRRLUULFFFDLDFBRDFDBDBRLFRFLUBDUUBDUBRBUR"},{"depth":30,"index":45,"facelets":"FDRLURULBRUDLRFBUFFDURFUDRLBBUDDFDBRDBLDLFRBLBRLLBFUUF"},{"depth":30,"index":46,"facelets":"DRUDUDBBDFLBFRFRBLRRRRFDLLDUBBFDUFDBLBULLFRLFLUFUBUDRU"},{"depth":30,"index":47,"facelets":"RULLURDUFDFULRDBLLRFLRFBFBDURLRDUFFFUDBFLDDURBBBBBLUDR"},{"depth":30,"index":48,"facelets":"FUULURBDBDBLRRFDBDRBRUFURFLFLBRDLBDRDDUULLLFUFFLDBBFRU"},{"depth":30,"index":49,"facelets":"URFUURLDLDDDLRFDRUFLBFFBUDBBBRBDFFUBRBDULLURRRUFDBLLFL"},{"depth":40,"index":0,"facelets":"URBDUBBFRFLLBRRLFFDUUUFRLRBFDDUDDRLUBLRBLBFLDUURFBDLFD"},{"depth":40,"index":1,"facelets":"URDDURLFFUBRBRLBDDBDRUFUFFRLUDRDRLLFBBULLLBUUFFRDBFLBD"},{"depth":40,"index":2,"facelets":"FFURUBDFUFUFLRRDRURRLFFUDLFBFLBDBLLRRDBLLUUDLRDDUBDBBB"},{"depth":40,"index":3,"facelets":"ULLUUFLBUBRDFRBBUBULRFFURBLBUUBDRFDDRLFRLLDRDFDFDBDLFR"},{"depth":40,"index":4,"facelets":"BLUFULDRFLUFDRFDDURBUBFLRRLDUBUDBLFBRDBRLUDFFRBULBDLRF"},{"depth":40,"index":5,"facelets":"RFBBURFULUFLDRBUULUFFLFRRRRDBBBDRBFDBLRLLUDDFULDUBDFDL"},{"depth":40,"index":6,"facelets":"URRUULBBBLUDRRDFRDRUDLFURDDFBRFDFULFLFUDLBLLUBBBFBRLDF"},{"depth":40,"index":7,"facelets":"FRRDUBURLDDDFRLLURRBBFFULUUULBRDBFLBDRFLLDDUFBFLFBDUBR"},{"depth":40,"index":8,"facelets":"DDUFUFDBLDDBLRBLRFRLBUFDUFFBLULDBFURFRBULFLURLRRDBRUBD"},{"depth":40,"index":9,"facelets":"LRUUUFDRLDLFBRDFUFLUBLFDBUDRBRRDFBBUBLFDLDRFURBUFBRLLD"},{"depth":40,"index":10,"facelets":"RLRLUDFUUFFBLRFUDLUBLRFDLFBFRLUDRDUBBURBLBRFDUBDLBDDRF"},{"depth":40,"index":11,"facelets":"DBUFURRDUBULBRLLLLFLRDFRDDDBFFFDFRUURRUULRFULFDBBBBBLD"},{"depth":40,"index":12,"facelets":"LULFULBBDRFBRRFUDBRDBBFDRBFDURDDLULLFUURLLLFFDRDRBBUUF"},{"depth":40,"index":13,"facelets":"UBDBURLULUFLBRFUDRFBFRFRBUFDRRLDLBUFLLDULDUFRBDBDBFDLR"},{"depth":40,"index":14,"facelets":"RFDBUBDDBLRRDRBFLLRFDRFRRULURDDDFFUUFUBBLFULBFUUDBLBLL"},{"depth":40,"index":15,"facelets":"RLFLUDBUFLRDRRFRLLLLUBFBLUBFRURDBUUUBFDDLDFFDRDDUBFBBR"},{"depth":40,"index":16,"facelets":"FFFDUULLBRFDRRFDBRUBUBFBFRLUUBUDDBDDDFFDLULLRRRLLBLBRU"},{"depth":40,"index":17,"facelets":"RUBDUFLRDRDULRFFURBBBBFDDRULFLRDLLUUDBUDLLBUFRBFLBRFFD"},{"depth":40,"index":18,"facelets":"FBLUURURUFUUDRLRDFBFLDFRBLDLUBBDLFLLRFRBLFRRDBDDFBUDBU"},{"depth":40,"index":19,"facelets":"BUUDUURLDRLFRRUFRBDDBLFDFLLLFDUDFUDDUFFBLBRRURFLBBRLBB"},{"depth":40,"index":20,"facelets":"RLLRUBDBLDDURRLRFDLRBUFDBRBDUULDDULRFFFULFLURBDUBBBFFF"},{"depth":40,"index":21,"facelets":"BRBRURFFFRDDDRLFUDDRDLFFRBUFDLLDFLUBRULBLUUDULBUFBLRBB"},{"depth":40,"index":22,"facelets":"DLBRULUBDLFURRRBUBFDFFFFUURLLDBDBRDDFDLBLUURBRDRUBLLFF"},{"depth":40,"index":23,"facelets":"RDUUURDFDLBLBRLULBRRFRFDRFBUULUDFFDDFBBFLUDLBFRUBBDLLR"},{"depth":40,"index":24,"facelets":"UBFFUULLUBRDURBLFLDDRLFLRRFBBUUDULDURRBDLFDBDRLFDBRBFF"},{"depth":40,"index":25,"facelets":"RDBUULFRDRFURRBBDLDBBDFFUBDLDLFDLFUFFRLULRDUBRFULBLUBR"},{"depth":40,"index":26,"facelets":"ULFFURBDFRDLBRDBBFUFDFFUFRRLUDBDRBURBULDLRDLUDFRBBLULL"},{"depth":40,"index":27,"facelets":"BFDDUUBFRFLRDRRBBUDUURFFDBLLLURDULDLRBRDLFBBFFLUUBRFLD"},{"depth":40,"index":28,"facelets":"BLFRUUBBLUBLURULRBRDFLFLRFUFUBRDBFLDDDUFLBDFUDDRRBDLFR"},{"depth":40,"index":29,"facelets":"UDBUUFUFLFLRFRRFLFBUDRFDLUDDLRBDBLRURBRBLDURBDLFFBDLUB"},{"depth":40,"index":30,"facelets":"URFDULDLBRFDRRLDBFRBUUFBFDLUFBLDULFLLBBULRFDRRDBUBFDRU"},{"depth":40,"index":31,"facelets":"LFLUULUDBRFFLRBDFRRRUUFDDBFBLLBDDDRDBBFULFRDLURURBLBUF"},{"depth":40,"index":32,"facelets":"RLDRUDBLBLBFFRLRUURUDBFLFFDRDBRDFUFBFBUDLULUDLDUBBRLRF"},{"depth":40,"index":33,"facelets":"DBBFUBULDFDRDRLFRBLFRFFLLRRUBUDDULDUBRBBLUDRFDLLUBURFF"},{"depth":40,"index":34,"facelets":"FRBUULBFRUFLBRUDDLDUBLFRDLRLBFFDLFDBLRRRLURDFUFUBBDDBU"},{"depth":40,"index":35,"facelets":"RLRFULFRDLFDURDFBBRFFBFRUDURBLDDRBULUUDFLLDRFBUBLBDUBL"},{"depth":40,"index":36,"facelets":"LRRUUUBRBRRUBRFUBFUBUUFDFDLRFFDDLBFLDLLULFRLDFDBRBBDLD"},{"depth":40,"index":37,"facelets":"BBLUUFRULFLBDRRRURDLDDFLUDUFRFFDBRBBURFBLFDULDLLFBDURB"},{"depth":40,"index":38,"facelets":"BRLUUDRRLBFDFRLRDFBDUDFRRUUDLFLDLUUULBDRLBRFFFUDBBBLFB"},{"depth":40,"index":39,"facelets":"UDBLUDLLDLBDBRBRRBUDFDFRRFFBUDLDFLRURBFFLFUUDLRFUBLRUB"},{"depth":40,"index":40,"facelets":"BFDRUBDRLFUBBRLLLFFDDUFRURFLUULDDRFRLFRBLFBUBRDDBBDULU"},{"depth":40,"index":41,"facelets":"DRFDUBBRBLDRURBBDLDDDUFFLFUFLRRDFBRFFLRLLLLBDUFRUBBUUU"},{"depth":40,"index":42,"facelets":"BFFLUBBLRUDLFRRRBRUFBLFUURUFBFFDURLDLULRLDFRLDDDDBUBBD"},{"depth":40,"index":43,"facelets":"ULDFULLDUFDFURRDBDBFLFFLRBBFURFDDDRLBRUBLURLULBRDBRBUF"},{"depth":40,"index":44,"facelets":"DBBRUUUDLULRURURFBBBFRFFDFULLFLDDFDLBFRBLDDBFDRLRBUULR"},{"depth":40,"index":45,"facelets":"DRLRULRFULDBLRDRLFDRBBFBRUDFLBUDFBFULBFULUUFUDDFBBRLDR"},{"depth":40,"index":46,"facelets":"RBBFUFBDDLLDBRULURDRFUFLLRUUFBLDBRRBFDRULLFDFLDUFBRUBD"},{"depth":40,"index":47,"facelets":"UDDUUBRURDRBFRLFDBFBFFFDFULLFDUDBLDDBLULLRURURLRFBBLRB"},{"depth":40,"index":48,"facelets":"LFFDURRRULFULRFRBFFBBLFDURDBUBFDUFBDDBURLULLRLDBUBDRLD"},{"depth":40,"index":49,"facelets":"FRLLUUFDRFFBLRULRLRBUUFDRLUUFBFDBDLFDBDFLRBDBDDLBBRUUR"},{"depth":50,"index":0,"facelets":"UBUBUURFLBBRURRBLFBRULFLFUDDFLUDFFDRFRDLLDDRLBDLDBBUFR"},{"depth":50,"index":1,"facelets":"BRDDUBFFLDURURRLDUULBFFLLFFFUULDBDLBRFRRLRBDDFUUDBBLBR"},{"depth":50,"index":2,"facelets":"BDFRURFFFLUDURLLLUUUUBFLBBUDDBDDDDURLFRRLLFRRRFDFBBBBL"},{"depth":50,"index":3,"facelets":"LRDDURBURDULLRUDRLRFFRFBBLBUFRDDBUFDULUULDFBLBFFBBLFDR"},{"depth":50,"index":4,"facelets":"UFDDULLBBRBBRRRUUFDRUDFDLFFBLRRDLRULFLBDLBFUURULFBFDBD"},{"depth":50,"index":5,"facelets":"DBUFUUBBBDLFURLLDLUDRFFBDDBFFDFDRUUFLRLDLURLRRRFBBLURB"},{"depth":50,"index":6,"facelets":"DUDFUBLBRUULBRRFDBFRBUFLDLURFLLDRFBLFDDFLLRDBBRRFBUUDU"},{"depth":50,"index":7,"facelets":"UBDLURRFBUULBRFFDDFRLLFRRFUDULDDLFRBBFUBLULBFBURDBLRDD"},{"depth":50,"index":8,"facelets":"DFLBULRFBLDULRFLURURDFFBRUFFBUUDRDDDLDBBLURLUBLFDBRBRF"},{"depth":50,"index":9,"facelets":"LLUFUDBLLDRFURBBFUUUBBFFDFRFLDDDRBRLFDLULDULRRBDRBBFUR"},{"depth":50,"index":10,"facelets":"FUBBUBDRLDLUDRLFBBLUBFFLLDRURUBDRLLRRUFDLRUDFRFDUBFDFB"},{"depth":50,"index":11,"facelets":"FDRUUUFBBDFBFRLUDLDRRRFLULLLDFRDFFBBRLLULFRDBUBDBBRDUU"},{"depth":50,"index":12,"facelets":"LUFUURBRFRFRDRBULUDBDFFFFFRLLBLDDBDBFRRBLUDBUULDDBULRL"},{"depth":50,"index":13,"facelets":"DFLLURRUUFBBFRDBUUFRLLFRBLRUUDLDFLBRFDUDLFDBLDDRRBBBUF"},{"depth":50,"index":14,"facelets":"LDRFUUDLFUFFURDBUFLFRRFLLRRDUDRDBBLUBRFDLBUDBDFULBBLBR"},{"depth":50,"index":15,"facelets":"LDDLUFUDLFLBFRRRLLRFDLFUFFBRRUUDBFUUDUFBLDURDRBBDBRBBL"},{"depth":50,"index":16,"facelets":"LRDDUDRUFDLLFRUBDRFFLBFLFRULBRFDFFBDBBUULLDRUBDURBLBUR"},{"depth":50,"index":17,"facelets":"LBURUDFRRDFBLRFDRBUFFUFUDLFRBLRDUBDUUBRDLFDDBLUFLBLRBL"},{"depth":50,"index":18,"facelets":"URRLUUUBRBRFRRUUDFLLDLFFLDLDBFDDRRULBFBLLUUFBDBRBBDDFF"},{"depth":50,"index":19,"facelets":"FLBBUDDBUFFLFRRRRUFULBFUDLURUFRDUDLRDDRBLRLFBUDLDBLBFB"},{"depth":50,"index":20,"facelets":"BBLUURBLDFBFLRDBDRUDRUFFFRRDFDRDFUDBLBLULFFULULDBBLURR"},{"depth":50,"index":21,"facelets":"DDDDUBUDFLULRRUFBLBBULFBBDRULUFDLRLDRFRFLFFRLBRBRBUFUD"},{"depth":50,"index":22,"facelets":"BDBFULURUBUDRRUUBDFBRLFFLRFFURDDLLURDLLDLDURDLFRFBBFBB"},{"depth":50,"index":23,"facelets":"LFBBULRLRFFDLRBLBRBDURFBDLUFUBFDDFUBFRDRLDUDRLRDUBUUFL"},{"depth":50,"index":24,"facelets":"LDDFUFFDBDLRURBLLUULRRFBURBLUDUDBBULFRRRLBUFBFFDDBDFLR"},{"depth":50,"index":25,"facelets":"DBUDULBUBUFRURRRRRLRLFFBDUULLFDDFLFFRLDDLDFRFBLBBBBDUU"},{"depth":50,"index":26,"facelets":"LRDBURRFFLDFLRBBUDBUUFFUULDRDLRDBBLRBDDBLDUUFLFURBLFFR"},{"depth":50,"index":27,"facelets":"RBDDURLRRDDBBRFFBBFUFDFRBBRLUULDLFRLUFDFLLUUDRDBUBLUFL"},{"depth":50,"index":28,"facelets":"FLRBUURFBUFDFRBFRLULLLFDBBDLRRRDUFDDUDBBLUUFDBDRUBLFRL"},{"depth":50,"index":29,"facelets":"URRLUULDURLDRRLFRUUFFBFULBLDRDFDDFBRLFFULDDUBBFBDBBBLR"},{"depth":50,"index":30,"facelets":"DDLUULDRRDFFLRFRFRRBFUFBDLBBUUDDULDULRBDLBUBLULFRBRFFB"},{"depth":50,"index":31,"facelets":"LUULURFLDLFRFRBLDURUFUFDDBFRRUDDBUUBDFDLLRFRBBFBLBDLBR"},{"depth":50,"index":32,"facelets":"BUDUULBFFUDFBRLUBDRURRFLUDLLRFLDDDRRDRUDLFLFBLBRUBFFBB"},{"depth":50,"index":33,"facelets":"RUFRUUBBULRULRDFFDDLBDFUUULRBDLDRBDRUBRFLBDDFLFBRBLFFL"},{"depth":50,"index":34,"facelets":"LLFDUUFRBUBLURLDBBUFLLFFUDRBBFBDRBRDUFRULFRLRDDFUBRLDD"},{"depth":50,"index":35,"facelets":"RRLLURDLRFBDURBLBRBUUDFFDRUFDBDDLLFBBFLBLFFLRFUDUBDURU"},{"depth":50,"index":36,"facelets":"ULUFURLRBLBFLRUURRUDDLFBDBBFULUDFLDDBDFFLDDRRRFRLBUBBF"},{"depth":50,"index":37,"facelets":"RLFUUDDRLBLUFRULFRFDULFLDDBRBDRDDLRBFBRFLUDBBLBURBUUFF"},{"depth":50,"index":38,"facelets":"URBUUBUFRDLDFRURDLBDFDFRRDUBRFFDBLLFLBRBLLDLDLUBFBRUUF"},{"depth":50,"index":39,"facelets":"URFLUUURDRBUBRRDDDRBBFFDBFFRRLFDFDULLDFLLURLULDBUBBBLF"},{"depth":50,"index":40,"facelets":"RLLDUFBLFLRDDRLBDRDBUDFFDRLBUUUDBRRBDLRULRUBLFUFFBFUBF"},{"depth":50,"index":41,"facelets":"RDLDURBRFDBFFRBRDRUULRFUBLUDFFBDLBRBDFLULFDURUBFLBLUDL"},{"depth":50,"index":42,"facelets":"ULDDUBFFLBLLRRDLUBUDULFBDDDRBFBDFDRUFLRRLURUBBFLRBFRUF"},{"depth":50,"index":43,"facelets":"UDRLURBBULUDURULBULLBUFLFRDRDFDDRRRLBDDLLBUFDBBRFBFFFF"},{"depth":50,"index":44,"facelets":"LRRBUFBFFUDDRRFFRFLLRLFBRBDUDRUDUBUUFLDLLDLBBBDDRBULFU"},{"depth":50,"index":45,"facelets":"ULUUUFFDDRDBDRFFRUURBBFBFLLRDDBDFDFRFLRRLULRDLBLLBUBUB"},{"depth":50,"index":46,"facelets":"RDFLULDRLFBUURUURDFBDDFFRFFBLRRDFULLUURRLFBUDLBBBBDBDL"},{"depth":50,"index":47,"facelets":"RDUFUBRBDBRLURFUBRDLLUFLULRLDBFDULLDFDFDLRDUBFBURBRBFF"},{"depth":50,"index":48,"facelets":"DDDBUDRRBULBLRBFUDDULDFUFRUUBLLDFBRFBLFRLBUFRRFLUBDLFR"},{"depth":50,"index":49,"facelets":"FBUFULDDFLUFRRRRUDRFUUFBLLBDBULDFLDRDRBBLRUFBRULDBDFLB"},{"depth":100,"index":0,"facelets":"BDRRURUFFRFUBRBBDLLUDDFUDBUBRRRDBLUFDUBFLFDDLFLRLBLULF"},{"depth":100,"index":1,"facelets":"BFUFUBUDLFRLLRRRRUBLDBFUFLDRFBBDDLUFLURULLUDDFDDFBBRRB"},{"depth":100,"index":2,"facelets":"BLBDUFLBUFRRRRBUUDDDLUFURLRFBBLDBLLRURBFLFDDUDFLRBDFUF"},{"depth":100,"index":3,"facelets":"FRFLUULDBDRUBRFRLBBLRRFDRFBDDUUDFDLDUBUULBFBFLDRRBFLUL"},{"depth":100,"index":4,"facelets":"RRBFURDFDBURLRFLBFLRLBFFBDURBBBDRRLUDDFULUULUDDFUBLLDF"},{"depth":100,"index":5,"facelets":"BFRDUBBRLURFLRLUBDRUFUFBDUFLFRDDDBFBULUFLBDRFDDLUBLRRL"},{"depth":100,"index":6,"facelets":"RULBURLUUBBUDRFFLBULRLFRDDRLFUDDFLBRDLFFLDBBFBRFRBUDUD"},{"depth":100,"index":7,"facelets":"LFDBUFBBFDULLRRDDURRLLFUDBBFURRDFRULBLUBLDUDRBLUFBDFRF"},{"depth":100,"index":8,"facelets":"DBURURFDFUURURLDLDLFRFFBLBRBRFDDULFBBDUFLRDBUBLLDBLRUF"},{"depth":100,"index":9,"facelets":"UFFFUUBRUBBRURRLDRLDRDFRLDDBBFUDFRFFFRDBLLDLUULLBBLDUB"},{"depth":100,"index":10,"facelets":"RBDUUFDRDLURRRFLDLRFFBFUBBBULDBDFULFULBRLUFRLFDBLBDUDR"},{"depth":100,"index":11,"facelets":"FRFUUFBDDRRDURLLFRURBFFRBBDLDFBDUBUULLLFLLULDRBUDBDFBR"},{"depth":100,"index":12,"facelets":"LLFBUDURFLFDFRBDRDBFURFUUDFLLLLDURRBDDRULDUBBRFBUBLRBF"},{"depth":100,"index":13,"facelets":"URRRULLFLUFUDRDDULBUFRFLRFLBDBLDLRBDBUUULDFBDFFRBBBFRD"},{"depth":100,"index":14,"facelets":"ULLUUDDUFRLBBRRBRLRRDDFDURUFFRUDDUFDRLBLLFBBLDBFBBFFUL"},{"depth":100,"index":15,"facelets":"RDFFUFFLBRLDBRRUFDLUUDFLLUBDBLRDDFUFBRULLRRUBRBDBBDLFU"},{"depth":100,"index":16,"facelets":"DRULUDFFDRBFRRFBLDLUBFFDUBULLRUDUDUFFDURLLLBBRFRDBBLRB"},{"depth":100,"index":17,"facelets":"BULUUDRLRDLDFRBFURFBFRFLDDURBLDDRLFBULUFLDBFBFBLRBRUUD"},{"depth":100,"index":18,"facelets":"DLLFULRRLFBBLRLURUFUDBFDFBBLDLUDFDBRFDUDLUBFUDFRUBRBRR"},{"depth":100,"index":19,"facelets":"DBLBULUDLBUFFRRRLRRLURFLRRFUUDDDBDDDLUFULFLRBUDFBBFBFB"},{"depth":100,"index":20,"facelets":"FFUDUFRBUBRLDRDRRDDLRUFBBDUUFFUDBBUBDLFULRDLLFLLRBBRFL"},{"depth":100,"index":21,"facelets":"RURDULUUBUDFBRBBBDFLLLFUDFUBDRUDRFLBFRLRLBLRLDFUDBFRFD"},{"depth":100,"index":22,"facelets":"URFBUDDUULBDRRLRRDLBBRFBUDDRFBUDDUDLFLFULFRLFRULFBFBLB"},{"depth":100,"index":23,"facelets":"DRBRURDBBRBLLRFFLRBUUFFDLFRFUUDDFULFRULULRLBDUDBDBLDBF"},{"depth":100,"index":24,"facelets":"DDUFUBFDBRUBLRLDBDUFULFFLURFRFBDDFBLRURRLUULDLRBDBFBRL"},{"depth":100,"index":25,"facelets":"RDBBUBUFDRLURRUUFLRRBBFUDFBBULFDDFDDDRFBLDULLRRFLBUFLL"},{"depth":100,"index":26,"facelets":"LFBRUDDDRUFLBRRBDRFLBLFDLBRFUDBDRBLUUURFLUDLDUUFBBRFFL"},{"depth":100,"index":27,"facelets":"URRDUURFFRLBLRLBRLFRDBFDBDDUFLUDDDLDFBUFLUBRLUBLFBUFBR"},{"depth":100,"index":28,"facelets":"UUDLUDRFBLBFRRRBUUBDDLFFFLLRDUUDBURRRBDULFLFDLLFBBRBDF"},{"depth":100,"index":29,"facelets":"LURRUFFURBLFRRRLLRDBDBFFBLURDBBDUFBUDULFLDULUDFBDBDFRL"},{"depth":100,"index":30,"facelets":"RFDBUDFBULRLLRUFDDURBBFFDDUFBLLDFDUFUURRLLBDRBUBRBFLLR"},{"depth":100,"index":31,"facelets":"BFDBULFUDLDRRRDFUULRFLFBRFRUDUUDBBUBLLUBLFRFBFRDRBDLLD"},{"depth":100,"index":32,"facelets":"FRBUUDDUDLBDFRFFDRRFFLFDBUURLLLDLUDURRBBLFBBULBDRBUFRL"},{"depth":100,"index":33,"facelets":"ULBUUDDURFLRFRRFFDBBURFUBBUURLDDLFDRBLLFLULRLDBRFBDFBD"},{"depth":100,"index":34,"facelets":"DFRFUUBBDFLUDRDDRFDLRDFBLFFBULUDFUUUBLRBLRRBUFDLLBRLRB"},{"depth":100,"index":35,"facelets":"DFUBUUFUULLLRRRFDBRRBUFDBFRRUULDLDDDRLDFLBFFUFDBBBRLBL"},{"depth":100,"index":36,"facelets":"FLFUURBDBLBUBRULBURBDRFLDDUFFBRDUBUFDFUFLDRFRLDLLBLRRD"},{"depth":100,"index":37,"facelets":"FDBUUFLRBRRLLRBRRRDBURFFRUUDLFFDDULDDFBDLULDFUBLUBLBBF"},{"depth":100,"index":38,"facelets":"DRLDUBDLULDFLRFDRBFBBFFFFUFULLBDUUDDRLRRLDRURUFBUBBLRB"},{"depth":100,"index":39,"facelets":"FLBFULDLDFBLDRDBRLRURBFFFFDURLRDDRUFDUBLLRBURUDLBBFUBU"},{"depth":100,"index":40,"facelets":"LLULUBRFLFLBRRFFDFUDDRFDDURBBUDDBDRDUFBFLBBLLLUFRBURUR"},{"depth":100,"index":41,"facelets":"UFDRULLRBDUFBRFLDRUFRLFRDDBFLDUDFLBUBUFRLBUBRLURLBDFDB"},{"depth":100,"index":42,"facelets":"BRULUUBFBUBFRRLUULLLLRFDBDLDFFLDFFRDRBDDLUDURRFUDBBFBR"},{"depth":100,"index":43,"facelets":"LRULUFUBDLDFBRRULBFUFLFDDFBRULRDDRRUDULBLFFUBRBBDBLRFD"},{"depth":100,"index":44,"facelets":"BFFLUBFDDFLLRRBUFUUFRUFBBFRDRBRDULLLLURRLBUDRDLDDBUFDB"},{"depth":100,"index":45,"facelets":"BBRFURDBRUUUDRFDLFRUBLFRDDLFBBLDBFLLURBULUUDRFRLDBFDFL"},{"depth":100,"index":46,"facelets":"DDFBULULDRBLFRURDFRDBRFUBRFUUDDDFBUUBRFFLFUBLDRLBBLLLR"},{"depth":100,"index":47,"facelets":"RFLFUBLRLDLDDRRBDUUDBLFLURRLUDDDFFLRDRFBLURBBFUFBBUBFU"},{"depth":100,"index":48,"facelets":"LUUUUDDRUFRBBRRDLBBFLDFLRDRFFFRDFBDRFBLLLLUUULFDBBUDBR"},{"depth":100,"index":49,"facelets":"LDBFURRDLUBDLRFRUDFRFUFUBDFRBDUDBDFFBLUBLRBFULLURBLLDR"}]
|
code/fixtures/prompt_pairs.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"depth": 3,
|
| 4 |
+
"index": 0,
|
| 5 |
+
"facelets": "BUUFUUFLLDLLRRURRUDBBDFFRFFBDDBDDFRRULLRLLRDDFFLBBUBBU",
|
| 6 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n B U U\n F U U\n F L L\nU L L D B B D L L F F L\nR L L D F F R R U B B U\nR D D R F F R R U B B U\n B D D\n B D D\n F R R\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 7 |
+
},
|
| 8 |
+
{
|
| 9 |
+
"depth": 3,
|
| 10 |
+
"index": 1,
|
| 11 |
+
"facelets": "BRRLUULUUBBDURDURDURRFFFFFFRRRDDDFLLUFFULDLLDBBLBBLBBD",
|
| 12 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n B R R\n L U U\n L U U\nU F F U R R B B D B B L\nU L D F F F U R D B B L\nL L D F F F U R D B B D\n R R R\n D D D\n F L L\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"depth": 3,
|
| 16 |
+
"index": 2,
|
| 17 |
+
"facelets": "UDBUUBUUBLLLRRRRRRFFDFFDFFDDDBDDBDUBRLLRLLRLLUBFUBFUBF",
|
| 18 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n U D B\n U U B\n U U B\nR L L F F D L L L U B F\nR L L F F D R R R U B F\nR L L F F D R R R U B F\n D D B\n D D B\n D U B\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"depth": 6,
|
| 22 |
+
"index": 0,
|
| 23 |
+
"facelets": "UUFBUFLFRDLLURRBBBURFUFBUUDRRLBDLBDLBDFLLFRRFDLRDBDUFD",
|
| 24 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n U U F\n B U F\n L F R\nB D F U R F D L L D L R\nL L F U F B U R R D B D\nR R F U U D B B B U F D\n R R L\n B D L\n B D L\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"depth": 6,
|
| 28 |
+
"index": 1,
|
| 29 |
+
"facelets": "BUDUUDUUFLBBRRRRRBRRUFFBFFFDDDDDDLLULFFULLULLRBDFBLRBB",
|
| 30 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n B U D\n U U D\n U U F\nL F F R R U L B B R B D\nU L L F F B R R R F B L\nU L L F F F R R B R B B\n D D D\n D D D\n L L U\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"depth": 6,
|
| 34 |
+
"index": 2,
|
| 35 |
+
"facelets": "BBFUUFLLBLURLRRBBDFFDFFDRRRUDDUDDFRRULDRLDUBBULLBBUFFL",
|
| 36 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n B B F\n U U F\n L L B\nU L D F F D L U R U L L\nR L D F F D L R R B B U\nU B B R R R B B D F F L\n U D D\n U D D\n F R R\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"depth": 20,
|
| 40 |
+
"index": 0,
|
| 41 |
+
"facelets": "LDBBURDFRFBLDRRRFRBRUFFLBLDRBBUDDUUFFULULLLLUUBDDBFDRF",
|
| 42 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n L D B\n B U R\n D F R\nF U L B R U F B L U B D\nU L L F F L D R R D B F\nL L U B L D R F R D R F\n R B B\n U D D\n U U F\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"depth": 20,
|
| 46 |
+
"index": 1,
|
| 47 |
+
"facelets": "LDULUFBDRBLFRRRFBULBDRFFUDDLRRUDUFDRFBDULBUFBRLDUBLBFL",
|
| 48 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n L D U\n L U F\n B D R\nF B D L B D B L F R L D\nU L B R F F R R R U B L\nU F B U D D F B U B F L\n L R R\n U D U\n F D R\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"depth": 20,
|
| 52 |
+
"index": 2,
|
| 53 |
+
"facelets": "FDBFURRLFLDDLRDBRDFBUFFUFBUDURLDULBBRLUFLUUDLLBDFBRRRB",
|
| 54 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n F D B\n F U R\n R L F\nR L U F B U L D D L B D\nF L U F F U L R D F B R\nU D L F B U B R D R R B\n D U R\n L D U\n L B B\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"depth": 100,
|
| 58 |
+
"index": 0,
|
| 59 |
+
"facelets": "BDRRURUFFRFUBRBBDLLUDDFUDBUBRRRDBLUFDUBFLFDDLFLRLBLULF",
|
| 60 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n B D R\n R U R\n U F F\nD U B L U D R F U F L R\nF L F D F U B R B L B L\nD D L D B U B D L U L F\n B R R\n R D B\n L U F\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"depth": 100,
|
| 64 |
+
"index": 1,
|
| 65 |
+
"facelets": "BFUFUBUDLFRLLRRRRUBLDBFUFLDRFBBDDLUFLURULLUDDFDDFBBRRB",
|
| 66 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n B F U\n F U B\n U D L\nL U R B L D F R L F D D\nU L L B F U L R R F B B\nU D D F L D R R U R R B\n R F B\n B D D\n L U F\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"depth": 100,
|
| 70 |
+
"index": 2,
|
| 71 |
+
"facelets": "BLBDUFLBUFRRRRBUUDDDLUFURLRFBBLDBLLRURBFLFDDUDFLRBDFUF",
|
| 72 |
+
"prompt": "Below is a scrambled Rubik's Cube, shown unfolded with all 6 faces visible. Each face is a 3x3 grid of stickers. Each letter names a sticker's color, using the face it belongs on when the cube is solved: U=White (Up), R=Red (Right), F=Green (Front), D=Yellow (Down), L=Orange (Left), B=Blue (Back).\n\nThe diagram is laid out as follows:\n- The first 3 lines (indented) are the U (Up) face.\n- The next 3 lines each hold four space-separated 3-sticker groups. Left to right, these groups are the L (Left), F (Front), R (Right) and B (Back) faces.\n- The last 3 lines (indented) are the D (Down) face.\n\nEach face's rows and columns are oriented as they appear once the cube is folded up:\n- U: its first line is the row touching B, and its last line is the row touching F.\n- D: its first line is the row touching F, and its last line is the row touching B.\n- L, F, R and B: the first line of each is the row touching U.\n- Reading a line from left to right: U, F and D run from L to R; R runs from F to B; L runs from B to F; and B runs from R to L.\n\n B L B\n D U F\n L B U\nU R B D D L F R R D F L\nF L F U F U R R B R B D\nD D U R L R U U D F U F\n F B B\n L D B\n L L R\n\nGive the sequence of moves that returns this cube to a fully solved state (all 9 stickers on each face matching).\nRespond with only the move sequence in standard notation: a space-separated list of face letters (U, D, L, R, F, or B), each optionally followed by ' for a counterclockwise turn or 2 for a double turn. Do not copy any example — work out the actual sequence for the cube shown above.\nDo not include any explanation, commentary, or code."
|
| 73 |
+
}
|
| 74 |
+
]
|
code/gen_data.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Training-data generator for the tiny cube-solving model.
|
| 3 |
+
|
| 4 |
+
Emits (state, solution) pairs as JSONL:
|
| 5 |
+
{"state": "<54 facelet chars>", "solution": "R U2 F' ...", "depth": <walk length>}
|
| 6 |
+
|
| 7 |
+
## Why states come from a random walk, not direct cubie sampling
|
| 8 |
+
|
| 9 |
+
Sampling a cube state directly (random piece permutation + orientations) is the
|
| 10 |
+
textbook approach, but it needs a consistent orientation reference per slot, and
|
| 11 |
+
the slot lists in `packages/engine/src/moveTables.ts` identify slots without
|
| 12 |
+
encoding one -- they are not ordered U/D-first with a consistent cyclic
|
| 13 |
+
direction. Sampling against them naively produces a valid cube only ~1 call in
|
| 14 |
+
37 (measured), because the corner-twist, edge-flip and permutation-parity
|
| 15 |
+
constraints are then being enforced against the wrong reference.
|
| 16 |
+
|
| 17 |
+
Walking from solved using the engine's own move tables sidesteps the whole
|
| 18 |
+
question: every reachable state is valid by construction, and the tables are
|
| 19 |
+
already cross-checked against an independent implementation. Measured against
|
| 20 |
+
the exact-uniform samples that *did* pass, the two agree on the distribution of
|
| 21 |
+
Kociemba solution length (mean 20.75 vs 21.0, both spanning 17-22), so the walk
|
| 22 |
+
is reaching the uniform distribution, not a biased corner of it.
|
| 23 |
+
|
| 24 |
+
## Why the walk is long
|
| 25 |
+
|
| 26 |
+
A deep walk is the point, not a cost. Every reachable state is at most 20 moves
|
| 27 |
+
from solved (God's Number), so a walk of 200 samples the same "fully mixed"
|
| 28 |
+
distribution as a walk of 50 or 1000 -- which is exactly why the model does not
|
| 29 |
+
need a depth curriculum to handle depth-50 and depth-100 benchmark buckets. It
|
| 30 |
+
never sees "depth"; it sees a state. Shallow depths are the rare tail, so they
|
| 31 |
+
are mixed in explicitly via --shallow-frac rather than left to chance.
|
| 32 |
+
"""
|
| 33 |
+
import argparse, json, os, random, sys, time
|
| 34 |
+
|
| 35 |
+
QT={
|
| 36 |
+
"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],
|
| 37 |
+
"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],
|
| 38 |
+
"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],
|
| 39 |
+
"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],
|
| 40 |
+
"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],
|
| 41 |
+
"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],
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
SOLVED = "".join("URFDLB"[i // 9] for i in range(54))
|
| 46 |
+
MOVES = [f + s for f in "URFDLB" for s in ("", "'", "2")]
|
| 47 |
+
_TURNS = {"": 1, "'": 3, "2": 2}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def apply_move(state: str, move: str) -> str:
|
| 51 |
+
"""Applies one move in standard notation to a 54-char facelet string."""
|
| 52 |
+
perm = QT[move[0]]
|
| 53 |
+
for _ in range(_TURNS[move[1:]]):
|
| 54 |
+
state = "".join(state[perm[i]] for i in range(54))
|
| 55 |
+
return state
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def apply_sequence(state: str, moves) -> str:
|
| 59 |
+
for m in moves:
|
| 60 |
+
state = apply_move(state, m)
|
| 61 |
+
return state
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def random_state(rng: random.Random, depth: int) -> str:
|
| 65 |
+
"""Walks `depth` moves from solved, never turning the same face twice in a row."""
|
| 66 |
+
state, prev = SOLVED, None
|
| 67 |
+
for _ in range(depth):
|
| 68 |
+
while True:
|
| 69 |
+
move = rng.choice(MOVES)
|
| 70 |
+
if move[0] != prev:
|
| 71 |
+
break
|
| 72 |
+
state = apply_move(state, move)
|
| 73 |
+
prev = move[0]
|
| 74 |
+
return state
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def check_kociemba_is_native(warn_only=False):
|
| 78 |
+
"""Refuses to run against kociemba's pure-Python fallback.
|
| 79 |
+
|
| 80 |
+
The package ships a C extension and a pure-Python implementation, and falls
|
| 81 |
+
back to the latter *silently* -- only a warning -- when the extension cannot
|
| 82 |
+
build (typically missing dev headers for whichever interpreter the venv was
|
| 83 |
+
made from). The fallback is roughly 50x slower, which turns a two-hour dataset
|
| 84 |
+
into a multi-day one. Since the only symptom is a slow log, this is checked
|
| 85 |
+
up front rather than discovered later: a rented machine generating at
|
| 86 |
+
fallback speed bills the whole time.
|
| 87 |
+
"""
|
| 88 |
+
import time
|
| 89 |
+
import kociemba
|
| 90 |
+
|
| 91 |
+
probe = "DRLUUBFBRBLURRLRUBLRDDFDLFUFUFFDBRDUBRUFLLFDDBFLUBLRBD"
|
| 92 |
+
kociemba.solve(probe) # warm the pruning tables before timing
|
| 93 |
+
start = time.time()
|
| 94 |
+
for _ in range(10):
|
| 95 |
+
kociemba.solve(probe)
|
| 96 |
+
ms = (time.time() - start) / 10 * 1000
|
| 97 |
+
if ms > 200:
|
| 98 |
+
msg = (f"kociemba is running at {ms:.0f} ms/solve, which is pure-Python "
|
| 99 |
+
f"fallback speed (native is ~20 ms). Install build-essential and "
|
| 100 |
+
f"python3-dev for this interpreter, then reinstall kociemba.")
|
| 101 |
+
if not warn_only:
|
| 102 |
+
raise SystemExit(f"ABORT: {msg}")
|
| 103 |
+
print(f"WARNING: {msg}", file=sys.stderr)
|
| 104 |
+
return ms
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def generate(count, seed, deep_depth, shallow_frac, max_shallow, augment, out):
|
| 108 |
+
import kociemba
|
| 109 |
+
|
| 110 |
+
rng = random.Random(seed)
|
| 111 |
+
kociemba.solve(random_state(rng, 20)) # warm the pruning tables
|
| 112 |
+
written = 0
|
| 113 |
+
start = time.time()
|
| 114 |
+
|
| 115 |
+
while written < count:
|
| 116 |
+
if rng.random() < shallow_frac:
|
| 117 |
+
depth = rng.randint(1, max_shallow)
|
| 118 |
+
else:
|
| 119 |
+
depth = deep_depth
|
| 120 |
+
state = random_state(rng, depth)
|
| 121 |
+
if state == SOLVED:
|
| 122 |
+
continue
|
| 123 |
+
solution = kociemba.solve(state).split()
|
| 124 |
+
|
| 125 |
+
# One Kociemba call also settles every state along its own solution path:
|
| 126 |
+
# after applying a prefix, the remaining suffix is a valid solve of the
|
| 127 |
+
# state you land on. That is a ~20x multiplier on a call costing ~23ms,
|
| 128 |
+
# and it is what makes a multi-million-pair dataset practical on few
|
| 129 |
+
# cores. The suffix labels are valid but not what Kociemba would itself
|
| 130 |
+
# emit for those states, so they are a slightly different (more
|
| 131 |
+
# multi-modal) target than the canonical pairs -- hence opt-in, and worth
|
| 132 |
+
# measuring against augment=0 before relying on it.
|
| 133 |
+
pairs = [(state, solution, depth)]
|
| 134 |
+
if augment:
|
| 135 |
+
walk = state
|
| 136 |
+
for i, move in enumerate(solution[:-1]):
|
| 137 |
+
walk = apply_move(walk, move)
|
| 138 |
+
if walk == SOLVED:
|
| 139 |
+
break
|
| 140 |
+
pairs.append((walk, solution[i + 1:], len(solution) - i - 1))
|
| 141 |
+
rng.shuffle(pairs)
|
| 142 |
+
pairs = pairs[:augment]
|
| 143 |
+
|
| 144 |
+
for st, sol, d in pairs:
|
| 145 |
+
if written >= count:
|
| 146 |
+
break
|
| 147 |
+
out.write(json.dumps({"state": st, "solution": " ".join(sol), "depth": d}) + "\n")
|
| 148 |
+
written += 1
|
| 149 |
+
|
| 150 |
+
if written % 20000 < len(pairs):
|
| 151 |
+
rate = written / max(time.time() - start, 1e-9)
|
| 152 |
+
print(f" {written}/{count} ({rate:.0f}/s)", file=sys.stderr)
|
| 153 |
+
|
| 154 |
+
return written
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _worker(job):
|
| 158 |
+
"""One shard, in its own process with its own seed and its own pruning tables."""
|
| 159 |
+
idx, count, args = job
|
| 160 |
+
path = f"{args['out']}.part{idx}"
|
| 161 |
+
with open(path, "w") as fh:
|
| 162 |
+
generate(count, args["seed"] + 1000 * idx, args["deep_depth"],
|
| 163 |
+
args["shallow_frac"], args["max_shallow"], args["augment"], fh)
|
| 164 |
+
return path
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def effective_cpus():
|
| 168 |
+
"""Number of CPUs this process may actually use.
|
| 169 |
+
|
| 170 |
+
`nproc` and os.cpu_count() report the *host's* cores, which on a rented
|
| 171 |
+
container is not what we get: a box advertising 128 cores routinely runs
|
| 172 |
+
under a cgroup quota of a fraction of that. Spawning one worker per host core
|
| 173 |
+
then puts 128 processes on ~16 CPUs, each running at an eighth speed. The
|
| 174 |
+
aggregate is unchanged -- it is capped by the quota either way -- but every
|
| 175 |
+
throughput estimate derived from the advertised core count is wrong by that
|
| 176 |
+
factor, which is how a 20-minute job came to be predicted for a 100-minute
|
| 177 |
+
one.
|
| 178 |
+
|
| 179 |
+
Prefers the scheduler affinity mask, then the cgroup v2 and v1 quotas.
|
| 180 |
+
"""
|
| 181 |
+
candidates = []
|
| 182 |
+
try:
|
| 183 |
+
candidates.append(len(os.sched_getaffinity(0)))
|
| 184 |
+
except AttributeError:
|
| 185 |
+
pass
|
| 186 |
+
for quota_path, period_path in (
|
| 187 |
+
("/sys/fs/cgroup/cpu.max", None),
|
| 188 |
+
("/sys/fs/cgroup/cpu/cpu.cfs_quota_us", "/sys/fs/cgroup/cpu/cpu.cfs_period_us"),
|
| 189 |
+
):
|
| 190 |
+
try:
|
| 191 |
+
raw = open(quota_path).read().split()
|
| 192 |
+
quota = raw[0]
|
| 193 |
+
period = int(raw[1]) if period_path is None else int(open(period_path).read())
|
| 194 |
+
if quota not in ("max", "-1"):
|
| 195 |
+
candidates.append(max(1, int(int(quota) / period)))
|
| 196 |
+
except (OSError, ValueError, IndexError):
|
| 197 |
+
pass
|
| 198 |
+
candidates.append(os.cpu_count() or 1)
|
| 199 |
+
return max(1, min(candidates))
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def generate_parallel(count, workers, args_dict, shards=None):
|
| 203 |
+
"""Shards generation across processes.
|
| 204 |
+
|
| 205 |
+
`shards` fixes the number of shards independently of `workers`. Per-shard
|
| 206 |
+
seeds are derived as seed + 1000*index, so the dataset produced depends on the
|
| 207 |
+
shard count -- meaning two machines with different core counts generate
|
| 208 |
+
*different* data from the same --seed. That is fine for training data and
|
| 209 |
+
quietly wrong for a shared holdout: two models meant to face identical puzzles
|
| 210 |
+
face merely similar ones, turning a paired comparison into an unpaired one.
|
| 211 |
+
Pass a fixed `shards` whenever the output must be reproducible across
|
| 212 |
+
machines.
|
| 213 |
+
"""
|
| 214 |
+
"""Shards generation across processes. Kociemba is CPU-bound and releases
|
| 215 |
+
nothing to threads, so processes are the only way to use more than one core --
|
| 216 |
+
and generation, not training, is the wall-clock bottleneck for this dataset."""
|
| 217 |
+
import multiprocessing as mp
|
| 218 |
+
|
| 219 |
+
n_shards = shards or workers
|
| 220 |
+
per = [count // n_shards] * n_shards
|
| 221 |
+
for i in range(count % n_shards):
|
| 222 |
+
per[i] += 1
|
| 223 |
+
jobs = [(i, per[i], args_dict) for i in range(n_shards) if per[i]]
|
| 224 |
+
with mp.Pool(min(workers, len(jobs))) as pool:
|
| 225 |
+
parts = pool.map(_worker, jobs)
|
| 226 |
+
|
| 227 |
+
written = 0
|
| 228 |
+
with open(args_dict["out"], "w") as out:
|
| 229 |
+
for path in parts:
|
| 230 |
+
with open(path) as fh:
|
| 231 |
+
for line in fh:
|
| 232 |
+
out.write(line)
|
| 233 |
+
written += 1
|
| 234 |
+
os.remove(path)
|
| 235 |
+
return written
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def main():
|
| 239 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 240 |
+
p.add_argument("--count", type=int, default=1_000_000)
|
| 241 |
+
p.add_argument("--seed", type=int, default=0)
|
| 242 |
+
p.add_argument("--deep-depth", type=int, default=200,
|
| 243 |
+
help="Walk length for the deep (fully mixed) majority of states.")
|
| 244 |
+
p.add_argument("--shallow-frac", type=float, default=0.15,
|
| 245 |
+
help="Fraction drawn from near-solved states instead.")
|
| 246 |
+
p.add_argument("--max-shallow", type=int, default=8)
|
| 247 |
+
p.add_argument("--augment", type=int, default=0, metavar="K",
|
| 248 |
+
help="Also emit up to K states from each solution path (0 = canonical pairs only).")
|
| 249 |
+
p.add_argument("--out", default="-")
|
| 250 |
+
p.add_argument("--shards", type=int, default=0,
|
| 251 |
+
help="Fix the shard count so output is identical across machines "
|
| 252 |
+
"regardless of core count. Required for a shared holdout.")
|
| 253 |
+
p.add_argument("--workers", type=int, default=0,
|
| 254 |
+
help="Parallel generator processes. 0 = the CPUs actually available "
|
| 255 |
+
"to this container, which is usually far fewer than nproc reports.")
|
| 256 |
+
args = p.parse_args()
|
| 257 |
+
|
| 258 |
+
ms = check_kociemba_is_native()
|
| 259 |
+
if args.workers <= 0:
|
| 260 |
+
args.workers = effective_cpus()
|
| 261 |
+
print(f"kociemba {ms:.1f} ms/solve | workers {args.workers} "
|
| 262 |
+
f"(host reports {os.cpu_count()})", file=sys.stderr)
|
| 263 |
+
|
| 264 |
+
if args.workers > 1:
|
| 265 |
+
if args.out == "-":
|
| 266 |
+
p.error("--workers needs --out (shards are merged into a file)")
|
| 267 |
+
start = time.time()
|
| 268 |
+
n = generate_parallel(args.count, args.workers, {
|
| 269 |
+
"out": args.out, "seed": args.seed, "deep_depth": args.deep_depth,
|
| 270 |
+
"shallow_frac": args.shallow_frac, "max_shallow": args.max_shallow,
|
| 271 |
+
"augment": args.augment}, shards=args.shards or None)
|
| 272 |
+
print(f"wrote {n} pairs in {time.time()-start:.0f}s "
|
| 273 |
+
f"({n/max(time.time()-start,1e-9):.0f}/s, {args.workers} workers)", file=sys.stderr)
|
| 274 |
+
return
|
| 275 |
+
|
| 276 |
+
out = sys.stdout if args.out == "-" else open(args.out, "w")
|
| 277 |
+
try:
|
| 278 |
+
n = generate(args.count, args.seed, args.deep_depth, args.shallow_frac,
|
| 279 |
+
args.max_shallow, args.augment, out)
|
| 280 |
+
print(f"wrote {n} pairs", file=sys.stderr)
|
| 281 |
+
finally:
|
| 282 |
+
if out is not sys.stdout:
|
| 283 |
+
out.close()
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
if __name__ == "__main__":
|
| 287 |
+
main()
|
code/gen_rollout_data.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generates training data from the states the model's *own* policy visits.
|
| 3 |
+
|
| 4 |
+
## Why
|
| 5 |
+
|
| 6 |
+
The model is trained only on states drawn from `gen_data.py`'s sampler, each
|
| 7 |
+
paired with one Kociemba solution. It is never trained on the states its own
|
| 8 |
+
attempts produce -- and measurement shows those are exactly where it is worst.
|
| 9 |
+
Tracking distance-to-solved across rounds of interactive solving, from a state 10
|
| 10 |
+
moves from solved:
|
| 11 |
+
|
| 12 |
+
10 -> 16 -> 17 -> 15 -> 17 -> 17 -> 18 -> 17 -> 18
|
| 13 |
+
|
| 14 |
+
It walks away from solved and stays there. Those visited states are off the
|
| 15 |
+
training distribution, so the model has never been shown what to do in them.
|
| 16 |
+
|
| 17 |
+
This is textbook exposure bias, and the fix is DAgger's: run the current policy,
|
| 18 |
+
collect the states it actually reaches, label those with the expert (Kociemba),
|
| 19 |
+
and train on them alongside the original data. Behaviour cloning alone compounds
|
| 20 |
+
error quadratically in horizon length; adding the policy's own state
|
| 21 |
+
distribution brings it back to linear.
|
| 22 |
+
|
| 23 |
+
Three other explanations have already been tested and eliminated -- more
|
| 24 |
+
parameters, more layers at matched parameters, and interactive chunking all
|
| 25 |
+
changed nothing -- which is what makes this the remaining candidate rather than
|
| 26 |
+
one option among several.
|
| 27 |
+
|
| 28 |
+
## How
|
| 29 |
+
|
| 30 |
+
Rollouts are batched on the GPU: a batch of states is decoded together, the first
|
| 31 |
+
`--chunk` moves of each proposal are applied, and the resulting states are
|
| 32 |
+
recorded. Labelling is the slow part and runs across all cores, exactly as in
|
| 33 |
+
gen_data.py.
|
| 34 |
+
|
| 35 |
+
python gen_rollout_data.py --checkpoint <hub-id-or-dir> --count 2000000 \\
|
| 36 |
+
--out data/rollout.jsonl
|
| 37 |
+
"""
|
| 38 |
+
import argparse, json, random, sys, time
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
|
| 41 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 42 |
+
from gen_data import (SOLVED, apply_sequence, random_state, effective_cpus,
|
| 43 |
+
check_kociemba_is_native)
|
| 44 |
+
import cube_tokenizer as T
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def collect_states(model, device, n_states, chunk, rounds, batch_size, rng, deep_depth,
|
| 48 |
+
shallow_frac, max_shallow):
|
| 49 |
+
"""Rolls the policy out and returns the states it lands in.
|
| 50 |
+
|
| 51 |
+
The start states are excluded: those are already in the training
|
| 52 |
+
distribution. Only what the policy *reaches* is new information.
|
| 53 |
+
"""
|
| 54 |
+
import torch
|
| 55 |
+
from serve import _decode
|
| 56 |
+
|
| 57 |
+
visited = []
|
| 58 |
+
while len(visited) < n_states:
|
| 59 |
+
batch = []
|
| 60 |
+
for _ in range(batch_size):
|
| 61 |
+
depth = rng.randint(1, max_shallow) if rng.random() < shallow_frac else deep_depth
|
| 62 |
+
batch.append(random_state(rng, depth))
|
| 63 |
+
|
| 64 |
+
for _ in range(rounds):
|
| 65 |
+
live = [s for s in batch if s != SOLVED]
|
| 66 |
+
if not live:
|
| 67 |
+
break
|
| 68 |
+
with torch.no_grad():
|
| 69 |
+
prompts = torch.tensor([T.encode_state(s) for s in live], device=device)
|
| 70 |
+
out = prompts
|
| 71 |
+
finished = torch.zeros(len(live), dtype=torch.bool, device=device)
|
| 72 |
+
for _ in range(T.MAX_SOLUTION + 1):
|
| 73 |
+
nxt = model(input_ids=out).logits[:, -1, :].argmax(-1)
|
| 74 |
+
nxt = torch.where(finished, torch.full_like(nxt, T.PAD), nxt)
|
| 75 |
+
finished |= nxt == T.EOS
|
| 76 |
+
out = torch.cat([out, nxt[:, None]], dim=1)
|
| 77 |
+
if bool(finished.all()):
|
| 78 |
+
break
|
| 79 |
+
proposals = [T.decode_solution(r) for r in out[:, prompts.shape[1]:].tolist()]
|
| 80 |
+
|
| 81 |
+
nxt_batch = []
|
| 82 |
+
for state, moves in zip(live, proposals):
|
| 83 |
+
if not moves:
|
| 84 |
+
continue
|
| 85 |
+
landed = apply_sequence(state, moves[:chunk])
|
| 86 |
+
if landed != SOLVED:
|
| 87 |
+
visited.append(landed)
|
| 88 |
+
nxt_batch.append(landed)
|
| 89 |
+
batch = nxt_batch
|
| 90 |
+
if not batch:
|
| 91 |
+
break
|
| 92 |
+
print(f" collected {len(visited)}/{n_states}", file=sys.stderr, flush=True)
|
| 93 |
+
return visited[:n_states]
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _label(state):
|
| 97 |
+
import kociemba
|
| 98 |
+
try:
|
| 99 |
+
return state, kociemba.solve(state).split()
|
| 100 |
+
except Exception:
|
| 101 |
+
return state, None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def main():
|
| 105 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 106 |
+
p.add_argument("--checkpoint", required=True)
|
| 107 |
+
p.add_argument("--count", type=int, default=2_000_000)
|
| 108 |
+
p.add_argument("--chunk", type=int, default=8)
|
| 109 |
+
p.add_argument("--rounds", type=int, default=4)
|
| 110 |
+
p.add_argument("--batch-size", type=int, default=512)
|
| 111 |
+
p.add_argument("--workers", type=int, default=0)
|
| 112 |
+
p.add_argument("--seed", type=int, default=7)
|
| 113 |
+
p.add_argument("--deep-depth", type=int, default=200)
|
| 114 |
+
p.add_argument("--shallow-frac", type=float, default=0.15)
|
| 115 |
+
p.add_argument("--max-shallow", type=int, default=8)
|
| 116 |
+
p.add_argument("--out", required=True)
|
| 117 |
+
args = p.parse_args()
|
| 118 |
+
|
| 119 |
+
ms = check_kociemba_is_native()
|
| 120 |
+
workers = args.workers or effective_cpus()
|
| 121 |
+
print(f"kociemba {ms:.1f} ms/solve | workers {workers}", file=sys.stderr)
|
| 122 |
+
|
| 123 |
+
import torch
|
| 124 |
+
from transformers import LlamaForCausalLM
|
| 125 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 126 |
+
model = LlamaForCausalLM.from_pretrained(args.checkpoint).to(device).eval()
|
| 127 |
+
print(f"rolling out {args.checkpoint} on {device}", file=sys.stderr)
|
| 128 |
+
|
| 129 |
+
start = time.time()
|
| 130 |
+
rng = random.Random(args.seed)
|
| 131 |
+
states = collect_states(model, device, args.count, args.chunk, args.rounds,
|
| 132 |
+
args.batch_size, rng, args.deep_depth, args.shallow_frac,
|
| 133 |
+
args.max_shallow)
|
| 134 |
+
print(f"collected {len(states)} states in {time.time()-start:.0f}s", file=sys.stderr)
|
| 135 |
+
|
| 136 |
+
import multiprocessing as mp
|
| 137 |
+
start = time.time()
|
| 138 |
+
written = 0
|
| 139 |
+
with mp.Pool(workers) as pool, open(args.out, "w") as fh:
|
| 140 |
+
for state, moves in pool.imap_unordered(_label, states, chunksize=64):
|
| 141 |
+
if not moves:
|
| 142 |
+
continue
|
| 143 |
+
fh.write(json.dumps({"state": state, "solution": " ".join(moves),
|
| 144 |
+
"depth": len(moves)}) + "\n")
|
| 145 |
+
written += 1
|
| 146 |
+
secs = time.time() - start
|
| 147 |
+
print(f"labelled and wrote {written} pairs in {secs:.0f}s ({written/max(secs,1e-9):.0f}/s)",
|
| 148 |
+
file=sys.stderr)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__":
|
| 152 |
+
main()
|
code/run_benchmark.sh
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Scores a trained checkpoint through the real harness and archives the result.
|
| 3 |
+
#
|
| 4 |
+
# Everything here goes through bench.ts, not a Python loop, so the model faces the
|
| 5 |
+
# canonical index-addressed scrambles and is scored by the same engine + Kociemba
|
| 6 |
+
# path as every LLM on the board. The output is an ordinary results row.
|
| 7 |
+
#
|
| 8 |
+
# ./run_benchmark.sh <checkpoint> [n-per-depth]
|
| 9 |
+
set -euo pipefail
|
| 10 |
+
|
| 11 |
+
CHECKPOINT="${1:?usage: run_benchmark.sh <checkpoint-dir-or-hub-id> [n]}"
|
| 12 |
+
N="${2:-200}"
|
| 13 |
+
PORT="${PORT:-8077}"
|
| 14 |
+
PY="${PY:-python3}"
|
| 15 |
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
| 16 |
+
STAMP="$(date -u +%Y-%m-%d)"
|
| 17 |
+
|
| 18 |
+
# n is large on purpose. The n=10 convention exists because API calls cost money;
|
| 19 |
+
# local inference is free, so the sampling noise that makes a 50% and an 80% cell
|
| 20 |
+
# statistically indistinguishable at n=10 can simply be removed.
|
| 21 |
+
echo "serving $CHECKPOINT on :$PORT"
|
| 22 |
+
"$PY" "$REPO_ROOT/training/serve.py" --checkpoint "$CHECKPOINT" --port "$PORT" &
|
| 23 |
+
SERVER=$!
|
| 24 |
+
trap 'kill $SERVER 2>/dev/null || true' EXIT
|
| 25 |
+
|
| 26 |
+
for _ in $(seq 1 60); do
|
| 27 |
+
curl -sS --max-time 2 "http://127.0.0.1:$PORT/v1/models" >/dev/null 2>&1 && break
|
| 28 |
+
sleep 2
|
| 29 |
+
done
|
| 30 |
+
|
| 31 |
+
cd "$REPO_ROOT"
|
| 32 |
+
mkdir -p results
|
| 33 |
+
# One depth per invocation with seed = depth * 1000. Passing several depths at once
|
| 34 |
+
# would derive them all from a single base seed and silently stop matching the
|
| 35 |
+
# canonical scrambles every other model was scored against.
|
| 36 |
+
for DEPTH in 3 6 10 15 20 25 30 40 50 100; do
|
| 37 |
+
OUT="results/tiny-cube-d${DEPTH}-${STAMP}.jsonl"
|
| 38 |
+
echo "=== depth $DEPTH (seed $((DEPTH * 1000)), n=$N) ==="
|
| 39 |
+
OPENROUTER_BASE_URL="http://127.0.0.1:$PORT/v1" OPENROUTER_API_KEY=local \
|
| 40 |
+
npx tsx scripts/bench.ts \
|
| 41 |
+
--models local/tiny-cube --depths "$DEPTH" --seed "$((DEPTH * 1000))" \
|
| 42 |
+
--n "$N" --m 1 --concurrency 4 --no-upload \
|
| 43 |
+
--timeout-ms 60000 --out "$OUT"
|
| 44 |
+
done
|
| 45 |
+
|
| 46 |
+
echo
|
| 47 |
+
echo "=== summary ==="
|
| 48 |
+
"$PY" - <<'PYEOF'
|
| 49 |
+
import glob, json
|
| 50 |
+
from collections import defaultdict
|
| 51 |
+
by = defaultdict(lambda: [0, 0])
|
| 52 |
+
for path in glob.glob("results/tiny-cube-d*.jsonl"):
|
| 53 |
+
for line in open(path):
|
| 54 |
+
r = json.loads(line)
|
| 55 |
+
by[r["depth"]][0] += bool(r.get("solved"))
|
| 56 |
+
by[r["depth"]][1] += 1
|
| 57 |
+
print(f"{'depth':>6} {'solved':>8} {'n':>6} {'rate':>8}")
|
| 58 |
+
for d in sorted(by):
|
| 59 |
+
ok, n = by[d]
|
| 60 |
+
print(f"{d:>6} {ok:>8} {n:>6} {ok/n:>7.1%}")
|
| 61 |
+
PYEOF
|
| 62 |
+
|
| 63 |
+
echo
|
| 64 |
+
echo "Next: back the JSONL into D1 and push it to R2 (see CLAUDE.md 'Where run artefacts live'):"
|
| 65 |
+
echo " python3 scripts/jsonl-to-sql.py out.sql results/tiny-cube-d*.jsonl"
|
| 66 |
+
echo " cd web && wrangler d1 execute rubiksbench --remote --file=../out.sql"
|
| 67 |
+
echo " cd web && for f in ../results/tiny-cube-d*.jsonl; do wrangler r2 object put \\"
|
| 68 |
+
echo " rubiksbench-logs/$STAMP/runs/\$(basename \$f) --file=\$f --remote; done"
|
code/search.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Search over cube states guided by a learned distance estimate.
|
| 3 |
+
|
| 4 |
+
The policy beam search in serve.py explores sequences the *policy* ranks highly.
|
| 5 |
+
This explores states the *value function* thinks are closest to solved, which is
|
| 6 |
+
a different and stronger thing: it can prefer a move the policy never considered,
|
| 7 |
+
because the ranking comes from an independent estimate rather than from the
|
| 8 |
+
policy's own confidence.
|
| 9 |
+
|
| 10 |
+
This is DeepCubeA's shape in miniature -- batch-weighted search with the engine
|
| 11 |
+
as the transition function and a network supplying the heuristic. The weighting
|
| 12 |
+
`h + lambda * g` trades solution length against search effort: lambda=0 is pure
|
| 13 |
+
greedy on the heuristic (fast, longer solutions), higher lambda behaves more like
|
| 14 |
+
uniform-cost search.
|
| 15 |
+
|
| 16 |
+
The heuristic is Kociemba length, an upper bound rather than true optimal
|
| 17 |
+
distance, so it is inadmissible and the search is not guaranteed to return a
|
| 18 |
+
shortest solution. That is fine: the engine verifies that whatever comes back
|
| 19 |
+
actually solves, and finding *a* solution at depth 20 is the open problem, not
|
| 20 |
+
finding the shortest.
|
| 21 |
+
"""
|
| 22 |
+
import sys
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
|
| 27 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 28 |
+
import cube_tokenizer as T
|
| 29 |
+
from gen_data import SOLVED, MOVES, apply_move
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def load_value_model(path, device):
|
| 33 |
+
from train_value import ValueNet
|
| 34 |
+
ckpt = torch.load(path, map_location=device)
|
| 35 |
+
model = ValueNet(ckpt["hidden"], ckpt["layers"], ckpt["heads"]).to(device)
|
| 36 |
+
model.load_state_dict(ckpt["state_dict"])
|
| 37 |
+
model.eval()
|
| 38 |
+
return model
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@torch.no_grad()
|
| 42 |
+
def heuristic(model, states, device, batch_size=1024):
|
| 43 |
+
"""Expected distance-to-solved for each state.
|
| 44 |
+
|
| 45 |
+
Uses the expectation over the predicted distribution rather than the argmax:
|
| 46 |
+
the extra resolution matters when ranking siblings that all round to the same
|
| 47 |
+
integer, which is most of the frontier.
|
| 48 |
+
"""
|
| 49 |
+
out = []
|
| 50 |
+
for i in range(0, len(states), batch_size):
|
| 51 |
+
chunk = states[i:i + batch_size]
|
| 52 |
+
ids = torch.tensor([T.encode_state(s) for s in chunk], device=device)
|
| 53 |
+
probs = torch.softmax(model(ids).float(), dim=-1)
|
| 54 |
+
values = torch.arange(probs.shape[-1], device=device, dtype=torch.float32)
|
| 55 |
+
out.extend((probs * values).sum(-1).tolist())
|
| 56 |
+
return out
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def solve_value_beam(model, state, device, beam=64, max_depth=26, lam=0.0,
|
| 60 |
+
batch_size=1024):
|
| 61 |
+
"""Beam search over states, ranked by `h + lam * g`.
|
| 62 |
+
|
| 63 |
+
Returns the move list that solves, or [] if the beam is exhausted. Visited
|
| 64 |
+
states are tracked globally: revisiting one cannot help, and on a group this
|
| 65 |
+
symmetric the frontier collapses onto duplicates quickly without it.
|
| 66 |
+
"""
|
| 67 |
+
if state == SOLVED:
|
| 68 |
+
return []
|
| 69 |
+
frontier = [(state, [])]
|
| 70 |
+
seen = {state}
|
| 71 |
+
|
| 72 |
+
for _ in range(max_depth):
|
| 73 |
+
children, paths = [], []
|
| 74 |
+
for s, path in frontier:
|
| 75 |
+
for mv in MOVES:
|
| 76 |
+
nxt = apply_move(s, mv)
|
| 77 |
+
if nxt in seen:
|
| 78 |
+
continue
|
| 79 |
+
if nxt == SOLVED:
|
| 80 |
+
return path + [mv]
|
| 81 |
+
seen.add(nxt)
|
| 82 |
+
children.append(nxt)
|
| 83 |
+
paths.append(path + [mv])
|
| 84 |
+
if not children:
|
| 85 |
+
return []
|
| 86 |
+
h = heuristic(model, children, device, batch_size)
|
| 87 |
+
scored = sorted(zip(h, children, paths), key=lambda x: x[0] + lam * len(x[2]))
|
| 88 |
+
frontier = [(c, p) for _, c, p in scored[:beam]]
|
| 89 |
+
return []
|
code/serve.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Serves the trained model behind an OpenAI-shaped /v1/chat/completions endpoint so
|
| 3 |
+
the existing harness can score it unchanged.
|
| 4 |
+
|
| 5 |
+
## Why a shim instead of a Python eval loop
|
| 6 |
+
|
| 7 |
+
The scrambles, the scorer, the JSONL writer, the D1 upload path and the
|
| 8 |
+
leaderboard are all in TypeScript, and the scramble seeds are index-addressed so
|
| 9 |
+
every model faces the identical puzzles. Re-implementing scramble generation in
|
| 10 |
+
Python to run a separate eval loop would risk silently diverging from those
|
| 11 |
+
scrambles, which would quietly destroy the matched-pairs property that makes
|
| 12 |
+
cross-model comparison meaningful. Pointing `callModel` at a local URL instead
|
| 13 |
+
keeps one harness, one scorer and one code path, and the model becomes just
|
| 14 |
+
another row in the results table.
|
| 15 |
+
|
| 16 |
+
Nothing here is a general LLM server. It extracts the cube state from whatever
|
| 17 |
+
prompt it is handed, greedily decodes a solution, and returns it in the response
|
| 18 |
+
shape the harness already parses.
|
| 19 |
+
"""
|
| 20 |
+
import argparse, json, re, time
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
from transformers import LlamaForCausalLM
|
| 24 |
+
|
| 25 |
+
import cube_tokenizer as T
|
| 26 |
+
|
| 27 |
+
def extract_state(text: str) -> str:
|
| 28 |
+
"""Reads the 54-facelet state out of the benchmark prompt's net diagram.
|
| 29 |
+
|
| 30 |
+
Parsed structurally rather than by regex over the whole prompt. The obvious
|
| 31 |
+
shortcut -- strip everything that is not a facelet letter and take 54 of them
|
| 32 |
+
-- silently produces a wrong state, because the surrounding prose is full of
|
| 33 |
+
face letters on both sides of the diagram: the preamble names the colours
|
| 34 |
+
("U=White (Up), R=Red (Right)...") and the trailing instruction lists the
|
| 35 |
+
move vocabulary ("(U, D, L, R, F, or B)"). Taking the first or last run picks
|
| 36 |
+
up prose either way, and the result still looks like a valid 54-character
|
| 37 |
+
state, so the failure would surface only as an unexplained low solve rate.
|
| 38 |
+
|
| 39 |
+
Stickers are space-separated individually, so a net line is either 3 cells
|
| 40 |
+
(a U or D row) or 12 cells (a row spanning L, F, R and B in that order).
|
| 41 |
+
Facelet strings are ordered U R F D L B, so the wide rows are split by face
|
| 42 |
+
and reassembled rather than concatenated in reading order.
|
| 43 |
+
"""
|
| 44 |
+
narrow, wide = [], []
|
| 45 |
+
for line in text.splitlines():
|
| 46 |
+
cells = line.split()
|
| 47 |
+
if not cells or not all(len(c) == 1 and c in "URFDLB" for c in cells):
|
| 48 |
+
continue
|
| 49 |
+
if len(cells) == 3:
|
| 50 |
+
narrow.append("".join(cells))
|
| 51 |
+
elif len(cells) == 12:
|
| 52 |
+
wide.append("".join(cells))
|
| 53 |
+
|
| 54 |
+
if len(narrow) != 6 or len(wide) != 3:
|
| 55 |
+
raise ValueError(f"unexpected net shape: {len(narrow)} narrow rows, {len(wide)} wide rows")
|
| 56 |
+
|
| 57 |
+
up = "".join(narrow[:3])
|
| 58 |
+
down = "".join(narrow[3:])
|
| 59 |
+
left = "".join(r[0:3] for r in wide)
|
| 60 |
+
front = "".join(r[3:6] for r in wide)
|
| 61 |
+
right = "".join(r[6:9] for r in wide)
|
| 62 |
+
back = "".join(r[9:12] for r in wide)
|
| 63 |
+
return up + right + front + down + left + back
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@torch.no_grad()
|
| 67 |
+
def _decode(model, state: str, device: str, temperature: float = 0.0, n: int = 1):
|
| 68 |
+
"""Decodes n solutions at once. temperature=0 is greedy (n is then pointless)."""
|
| 69 |
+
prompt = torch.tensor([T.encode_state(state)] * n, device=device)
|
| 70 |
+
ids = prompt
|
| 71 |
+
finished = torch.zeros(n, dtype=torch.bool, device=device)
|
| 72 |
+
for _ in range(T.MAX_SOLUTION + 1):
|
| 73 |
+
logits = model(input_ids=ids).logits[:, -1, :]
|
| 74 |
+
if temperature > 0:
|
| 75 |
+
nxt = torch.multinomial(torch.softmax(logits / temperature, dim=-1), 1)[:, 0]
|
| 76 |
+
else:
|
| 77 |
+
nxt = logits.argmax(-1)
|
| 78 |
+
nxt = torch.where(finished, torch.full_like(nxt, T.PAD), nxt)
|
| 79 |
+
finished |= nxt == T.EOS
|
| 80 |
+
ids = torch.cat([ids, nxt[:, None]], dim=1)
|
| 81 |
+
if bool(finished.all()):
|
| 82 |
+
break
|
| 83 |
+
start = prompt.shape[1]
|
| 84 |
+
return [T.decode_solution(row) for row in ids[:, start:].tolist()]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def solve(model, state: str, device: str, attempts: int = 1, temperature: float = 1.5) -> str:
|
| 88 |
+
"""Returns a solution, verifying candidates against the engine when allowed more
|
| 89 |
+
than one attempt.
|
| 90 |
+
|
| 91 |
+
With attempts=1 this is a plain greedy decode -- the bare-model number, and the
|
| 92 |
+
one to quote as the model's own solve rate.
|
| 93 |
+
|
| 94 |
+
With attempts>1 the model proposes and the engine disposes: sampled candidates
|
| 95 |
+
are each replayed, and the first that actually solves is returned. That is
|
| 96 |
+
legitimate (the verifier is exact and free) but it is a *system* score, not a
|
| 97 |
+
model score, so the two must be reported separately and never blended. It
|
| 98 |
+
exists because a 20-move one-shot solve needs per-move accuracy around 0.9995
|
| 99 |
+
to hit 99% unaided, whereas a handful of verified samples buys the same result
|
| 100 |
+
from a much weaker model.
|
| 101 |
+
|
| 102 |
+
Temperature defaults to 1.5, which is high on purpose. Measured on the sanity
|
| 103 |
+
checkpoint, 16 samples per state collapsed to only 2.8 distinct solutions at
|
| 104 |
+
0.7 and rescued *none* of the greedy failures; at 1.0, 4.0 distinct and 1 of 5;
|
| 105 |
+
at 1.5, 11.0 distinct and 2 of 5. The model is confidently wrong rather than
|
| 106 |
+
uncertain, so mild sampling just re-draws the same wrong answer -- the
|
| 107 |
+
candidates have to be genuinely diverse before an exact verifier can do any
|
| 108 |
+
work. A wrong candidate costs nothing here, since every one is checked.
|
| 109 |
+
"""
|
| 110 |
+
from gen_data import SOLVED, apply_sequence
|
| 111 |
+
|
| 112 |
+
greedy = _decode(model, state, device)[0]
|
| 113 |
+
if greedy and apply_sequence(state, greedy) == SOLVED:
|
| 114 |
+
return " ".join(greedy)
|
| 115 |
+
if attempts <= 1:
|
| 116 |
+
return " ".join(greedy)
|
| 117 |
+
|
| 118 |
+
for cand in _decode(model, state, device, temperature=temperature, n=attempts - 1):
|
| 119 |
+
if cand and apply_sequence(state, cand) == SOLVED:
|
| 120 |
+
return " ".join(cand)
|
| 121 |
+
return " ".join(greedy)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@torch.no_grad()
|
| 125 |
+
def solve_beam(model, state: str, device: str, beam: int = 16, top_k: int = 6):
|
| 126 |
+
"""Beam search over the model's move distribution, verified by the engine.
|
| 127 |
+
|
| 128 |
+
Greedy decoding commits to the single most likely move at every step. Beam
|
| 129 |
+
search keeps the `beam` highest-probability partial sequences instead, so a
|
| 130 |
+
solution the model ranks second or third at one step is still reachable. The
|
| 131 |
+
engine checks every completed candidate, so a wrong one costs nothing.
|
| 132 |
+
|
| 133 |
+
This is the cheap half of the search hypothesis. Four attempts to fix the
|
| 134 |
+
~8-move wall by changing the *model* -- more parameters, more layers,
|
| 135 |
+
interactive chunking, training on its own rollouts -- all failed, and what
|
| 136 |
+
they had in common is the shape: one forward pass, one emitted sequence, no
|
| 137 |
+
search. The literature does not use that shape. DeepCubeA learns a value
|
| 138 |
+
function and runs A* over it; Kociemba is IDA* over pruning tables.
|
| 139 |
+
|
| 140 |
+
Note what this does and does not add. It searches the model's *own*
|
| 141 |
+
distribution, so it can recover a solution the model ranked highly but not
|
| 142 |
+
first. It supplies no independent notion of distance-to-solved, so if the
|
| 143 |
+
model's distribution is simply wrong at a given state -- as the rollout
|
| 144 |
+
traces suggest it is beyond ~8 moves -- beam search has nothing better to
|
| 145 |
+
find. A negative here therefore points at the value-function half of the
|
| 146 |
+
hypothesis rather than closing it off.
|
| 147 |
+
"""
|
| 148 |
+
from gen_data import SOLVED, apply_sequence
|
| 149 |
+
|
| 150 |
+
prompt = T.encode_state(state)
|
| 151 |
+
beams = [(0.0, [])] # (cumulative logprob, move-token ids)
|
| 152 |
+
finished: list[tuple[float, list[int]]] = []
|
| 153 |
+
|
| 154 |
+
for _ in range(T.MAX_SOLUTION + 1):
|
| 155 |
+
if not beams:
|
| 156 |
+
break
|
| 157 |
+
ids = torch.tensor([prompt + b[1] for b in beams], device=device)
|
| 158 |
+
logprobs = torch.log_softmax(model(input_ids=ids).logits[:, -1, :], dim=-1)
|
| 159 |
+
cand_lp, cand_ix = logprobs.topk(top_k, dim=-1)
|
| 160 |
+
|
| 161 |
+
pool = []
|
| 162 |
+
for (score, seq), lps, ixs in zip(beams, cand_lp.tolist(), cand_ix.tolist()):
|
| 163 |
+
for lp, ix in zip(lps, ixs):
|
| 164 |
+
if ix == T.EOS:
|
| 165 |
+
finished.append((score + lp, seq))
|
| 166 |
+
else:
|
| 167 |
+
pool.append((score + lp, seq + [ix]))
|
| 168 |
+
pool.sort(key=lambda x: -x[0])
|
| 169 |
+
beams = pool[:beam]
|
| 170 |
+
|
| 171 |
+
# Most probable first: the engine is exact, so the only cost of a wrong
|
| 172 |
+
# candidate is the replay.
|
| 173 |
+
for _, seq in sorted(finished, key=lambda x: -x[0]):
|
| 174 |
+
moves = T.decode_solution(seq + [T.EOS])
|
| 175 |
+
if moves and apply_sequence(state, moves) == SOLVED:
|
| 176 |
+
return moves
|
| 177 |
+
return T.decode_solution((finished[0][1] if finished else []) + [T.EOS])
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def solve_chunked(model, state: str, device: str, chunk: int = 8,
|
| 181 |
+
max_rounds: int = 12, max_moves: int = 200):
|
| 182 |
+
"""Solves by repeatedly showing the model the *true* current state.
|
| 183 |
+
|
| 184 |
+
The model proposes a full solution, only its first `chunk` moves are applied,
|
| 185 |
+
and it is then shown the real resulting state and asked again. The returned
|
| 186 |
+
value is the concatenation of every applied chunk -- a single move sequence
|
| 187 |
+
from the original state, so it is scored exactly like a one-shot answer.
|
| 188 |
+
|
| 189 |
+
## Why this should work where more parameters and more layers did not
|
| 190 |
+
|
| 191 |
+
A one-shot solve is blind after move 1: every later move reasons about a cube
|
| 192 |
+
the model imagined, so a single early error makes the whole remainder correct
|
| 193 |
+
reasoning about a state that does not exist. Success is roughly p^L for
|
| 194 |
+
per-move accuracy p, which is why this model is ~100% at 3 moves and ~0% at
|
| 195 |
+
20 -- and why neither doubling parameters nor doubling depth moved that wall.
|
| 196 |
+
Re-showing the true state resets the compounding: each round only needs the
|
| 197 |
+
model to be right over `chunk` moves, which is the regime it is already good
|
| 198 |
+
at.
|
| 199 |
+
|
| 200 |
+
Predicting more than is executed is deliberate, and borrowed from robot
|
| 201 |
+
action-chunking: the model plans a whole solution and only the confident
|
| 202 |
+
front of it is used, so each applied move was chosen with the full plan in
|
| 203 |
+
view rather than greedily.
|
| 204 |
+
|
| 205 |
+
**This makes the task interactive rather than one-shot**, which is a
|
| 206 |
+
different thing to measure. Results belong in their own mode and must never
|
| 207 |
+
share a table with one-shot scores.
|
| 208 |
+
"""
|
| 209 |
+
from gen_data import SOLVED, apply_sequence
|
| 210 |
+
|
| 211 |
+
applied: list[str] = []
|
| 212 |
+
current = state
|
| 213 |
+
seen = {current}
|
| 214 |
+
for _ in range(max_rounds):
|
| 215 |
+
if current == SOLVED:
|
| 216 |
+
break
|
| 217 |
+
proposed = _decode(model, current, device)[0]
|
| 218 |
+
if not proposed:
|
| 219 |
+
break
|
| 220 |
+
# Take the whole proposal when the engine confirms it finishes the job.
|
| 221 |
+
# Truncating a correct answer to `chunk` is strictly harmful: it leaves the
|
| 222 |
+
# cube one move short and forces another round, which is why chunk=8 turned
|
| 223 |
+
# 9-move depth-6 solves into ~36-move ones at no gain in success rate. The
|
| 224 |
+
# verifier is exact and free, so there is no reason to guess here.
|
| 225 |
+
take = proposed if apply_sequence(current, proposed) == SOLVED else proposed[:chunk]
|
| 226 |
+
current = apply_sequence(current, take)
|
| 227 |
+
applied.extend(take)
|
| 228 |
+
if len(applied) >= max_moves:
|
| 229 |
+
break
|
| 230 |
+
# No progress: the model is proposing something that returns to a state
|
| 231 |
+
# already visited, so more rounds cannot help.
|
| 232 |
+
if current in seen:
|
| 233 |
+
break
|
| 234 |
+
seen.add(current)
|
| 235 |
+
return applied
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def build_app(model, device, model_name, args):
|
| 239 |
+
from fastapi import FastAPI, Request
|
| 240 |
+
|
| 241 |
+
app = FastAPI()
|
| 242 |
+
|
| 243 |
+
@app.get("/v1/models")
|
| 244 |
+
def models():
|
| 245 |
+
return {"object": "list", "data": [{"id": model_name, "object": "model"}]}
|
| 246 |
+
|
| 247 |
+
@app.post("/v1/chat/completions")
|
| 248 |
+
async def completions(req: Request):
|
| 249 |
+
body = await req.json()
|
| 250 |
+
text = "\n".join(m.get("content") or "" for m in body.get("messages", []))
|
| 251 |
+
t0 = time.time()
|
| 252 |
+
try:
|
| 253 |
+
answer = solve(model, extract_state(text), device, attempts=args.attempts)
|
| 254 |
+
except ValueError as e:
|
| 255 |
+
answer = f"ERROR: {e}"
|
| 256 |
+
return {
|
| 257 |
+
"id": "cube-local", "object": "chat.completion",
|
| 258 |
+
"created": int(t0), "model": model_name,
|
| 259 |
+
"choices": [{"index": 0, "finish_reason": "stop",
|
| 260 |
+
"message": {"role": "assistant", "content": answer}}],
|
| 261 |
+
# Reported honestly: this model has no hidden reasoning, so the
|
| 262 |
+
# harness should record zero rather than infer any from a blank field.
|
| 263 |
+
"usage": {"prompt_tokens": T.MAX_SEQ, "completion_tokens": len(answer.split()),
|
| 264 |
+
"total_tokens": T.MAX_SEQ + len(answer.split()),
|
| 265 |
+
"completion_tokens_details": {"reasoning_tokens": 0}},
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
return app
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def main():
|
| 272 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 273 |
+
p.add_argument("--checkpoint", required=True, help="Local dir or Hub repo id.")
|
| 274 |
+
p.add_argument("--port", type=int, default=8000)
|
| 275 |
+
p.add_argument("--model-name", default="local/tiny-cube")
|
| 276 |
+
p.add_argument("--attempts", type=int, default=1,
|
| 277 |
+
help="1 = bare greedy model score. >1 samples extra candidates and "
|
| 278 |
+
"keeps the first the engine confirms solves (a system score).")
|
| 279 |
+
p.add_argument("--self-test", type=int, default=0,
|
| 280 |
+
help="Solve N generated scrambles and exit, instead of serving.")
|
| 281 |
+
args = p.parse_args()
|
| 282 |
+
|
| 283 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 284 |
+
model = LlamaForCausalLM.from_pretrained(args.checkpoint).to(device).eval()
|
| 285 |
+
print(f"loaded {args.checkpoint} on {device}", flush=True)
|
| 286 |
+
|
| 287 |
+
if args.self_test:
|
| 288 |
+
import random
|
| 289 |
+
from gen_data import SOLVED, apply_sequence, random_state
|
| 290 |
+
rng = random.Random(0)
|
| 291 |
+
solved = 0
|
| 292 |
+
for _ in range(args.self_test):
|
| 293 |
+
state = random_state(rng, 200)
|
| 294 |
+
moves = solve(model, state, device).split()
|
| 295 |
+
if moves and apply_sequence(state, moves) == SOLVED:
|
| 296 |
+
solved += 1
|
| 297 |
+
print(f"self-test: {solved}/{args.self_test} solved ({solved/args.self_test:.1%})")
|
| 298 |
+
return
|
| 299 |
+
|
| 300 |
+
import uvicorn
|
| 301 |
+
uvicorn.run(build_app(model, device, args.model_name, args), host="0.0.0.0", port=args.port)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
if __name__ == "__main__":
|
| 305 |
+
main()
|
code/test_serve.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Regression test for prompt parsing.
|
| 3 |
+
|
| 4 |
+
Worth having as a standing test rather than a one-off check, because a wrong
|
| 5 |
+
parse is invisible: it still yields a syntactically valid 54-character state, so
|
| 6 |
+
the model is simply asked to solve a different cube and the damage shows up only
|
| 7 |
+
as an unexplained low benchmark score. The fixture is generated by the real
|
| 8 |
+
renderer (scripts/print-prompt-pairs.ts), so it also fails if the prompt layout
|
| 9 |
+
ever changes.
|
| 10 |
+
|
| 11 |
+
python -m pytest test_serve.py # or: python test_serve.py
|
| 12 |
+
"""
|
| 13 |
+
import json
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from serve import extract_state
|
| 17 |
+
|
| 18 |
+
FIXTURE = Path(__file__).parent / "fixtures" / "prompt_pairs.json"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load():
|
| 22 |
+
return json.loads(FIXTURE.read_text())
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_extracts_state_from_real_prompts():
|
| 26 |
+
for case in load():
|
| 27 |
+
got = extract_state(case["prompt"])
|
| 28 |
+
assert got == case["facelets"], (
|
| 29 |
+
f"depth {case['depth']} index {case['index']}:\n"
|
| 30 |
+
f" want {case['facelets']}\n got {got}")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_rejects_prose_without_a_net():
|
| 34 |
+
"""The prompt's prose alone contains plenty of face letters -- naming the
|
| 35 |
+
colours up front and the move vocabulary at the end. Parsing must fail loudly
|
| 36 |
+
on text with no diagram rather than assembling a state out of that prose."""
|
| 37 |
+
prose = "Use U, D, L, R, F, or B. U=White (Up), R=Red (Right), F=Green (Front)."
|
| 38 |
+
try:
|
| 39 |
+
extract_state(prose)
|
| 40 |
+
except ValueError:
|
| 41 |
+
return
|
| 42 |
+
raise AssertionError("expected ValueError on prompt with no net diagram")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
if __name__ == "__main__":
|
| 46 |
+
test_extracts_state_from_real_prompts()
|
| 47 |
+
test_rejects_prose_without_a_net()
|
| 48 |
+
print(f"ok: parsed all {len(load())} fixture prompts; rejected prose-only input")
|
code/train.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Trains the tiny cube-solving model.
|
| 3 |
+
|
| 4 |
+
Architecture is a stock LlamaConfig rather than a bespoke nn.Module. The model is
|
| 5 |
+
identical either way, but a standard architecture loads with plain
|
| 6 |
+
`transformers` (no trust_remote_code), pushes to the Hub cleanly, and stays
|
| 7 |
+
compatible with the wider tooling if it is ever wanted. That costs a config
|
| 8 |
+
object instead of a class.
|
| 9 |
+
|
| 10 |
+
## The metric is solve rate, not loss
|
| 11 |
+
|
| 12 |
+
Token accuracy and validation loss are both misleading here. A cube has
|
| 13 |
+
astronomically many valid solutions and Kociemba emits one of them, so a model
|
| 14 |
+
that produces a *different* valid solve scores badly on token match and
|
| 15 |
+
perfectly on the only thing that matters. The harness scores by applying the
|
| 16 |
+
moves and asking the engine whether the cube ended solved, so evaluation here
|
| 17 |
+
does the same. Watch `solve_rate`; loss is only useful for spotting divergence.
|
| 18 |
+
"""
|
| 19 |
+
import argparse, json, math, os, random, sys, time
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import torch
|
| 24 |
+
import torch.nn.functional as F
|
| 25 |
+
from torch.utils.data import Dataset, DataLoader
|
| 26 |
+
|
| 27 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 28 |
+
import cube_tokenizer as T
|
| 29 |
+
from gen_data import SOLVED, apply_sequence
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class CubeDataset(Dataset):
|
| 33 |
+
def __init__(self, rows):
|
| 34 |
+
self.rows = rows
|
| 35 |
+
|
| 36 |
+
def __len__(self):
|
| 37 |
+
return len(self.rows)
|
| 38 |
+
|
| 39 |
+
def __getitem__(self, i):
|
| 40 |
+
state, solution = self.rows[i]
|
| 41 |
+
ids, labels = T.encode_pair(state, solution)
|
| 42 |
+
return torch.tensor(ids), torch.tensor(labels)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def collate(batch):
|
| 46 |
+
n = max(len(x[0]) for x in batch)
|
| 47 |
+
ids = torch.full((len(batch), n), T.PAD, dtype=torch.long)
|
| 48 |
+
labels = torch.full((len(batch), n), -100, dtype=torch.long)
|
| 49 |
+
mask = torch.zeros((len(batch), n), dtype=torch.long)
|
| 50 |
+
for i, (a, b) in enumerate(batch):
|
| 51 |
+
ids[i, : len(a)] = a
|
| 52 |
+
labels[i, : len(b)] = b
|
| 53 |
+
mask[i, : len(a)] = 1
|
| 54 |
+
return ids, labels, mask
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def load_jsonl(path, limit=0):
|
| 58 |
+
rows = []
|
| 59 |
+
with open(path) as fh:
|
| 60 |
+
for line in fh:
|
| 61 |
+
if limit and len(rows) >= limit:
|
| 62 |
+
break
|
| 63 |
+
r = json.loads(line)
|
| 64 |
+
moves = r["solution"].split()
|
| 65 |
+
if len(moves) > T.MAX_SOLUTION:
|
| 66 |
+
continue
|
| 67 |
+
rows.append((r["state"], moves))
|
| 68 |
+
return rows
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@torch.no_grad()
|
| 72 |
+
def solve_rate(model, rows, device, max_new=T.MAX_SOLUTION + 1, batch_size=256):
|
| 73 |
+
"""Greedy-decodes each state and reports the fraction that solve, plus a
|
| 74 |
+
breakdown by solution length.
|
| 75 |
+
|
| 76 |
+
The breakdown is not decoration. A single aggregate over this holdout is
|
| 77 |
+
saturated by construction: the set is ~15% near-solved states and ~85%
|
| 78 |
+
fully-mixed ones, so a model whose reach stops at 8 moves cannot score above
|
| 79 |
+
~15% however well it trains. Watching only the aggregate showed a flat 11-12%
|
| 80 |
+
for 13,000 steps while the model was in fact going from 52% to 72% on
|
| 81 |
+
seven-move solves -- the progress was real and entirely invisible.
|
| 82 |
+
|
| 83 |
+
Bucketed by label length rather than by the recorded scramble depth, since
|
| 84 |
+
depth stops tracking difficulty past ~15 moves (every deep scramble is ~20
|
| 85 |
+
moves from solved) and length is what actually governs whether the model can
|
| 86 |
+
do it.
|
| 87 |
+
"""
|
| 88 |
+
model.eval()
|
| 89 |
+
solved = 0
|
| 90 |
+
buckets = defaultdict(lambda: [0, 0])
|
| 91 |
+
for start in range(0, len(rows), batch_size):
|
| 92 |
+
chunk = rows[start : start + batch_size]
|
| 93 |
+
prompts = torch.tensor([T.encode_state(s) for s, _ in chunk], device=device)
|
| 94 |
+
out = prompts
|
| 95 |
+
finished = torch.zeros(len(chunk), dtype=torch.bool, device=device)
|
| 96 |
+
for _ in range(max_new):
|
| 97 |
+
logits = model(input_ids=out).logits[:, -1, :]
|
| 98 |
+
nxt = logits.argmax(-1)
|
| 99 |
+
nxt[finished] = T.PAD
|
| 100 |
+
finished |= nxt == T.EOS
|
| 101 |
+
out = torch.cat([out, nxt[:, None]], dim=1)
|
| 102 |
+
if finished.all():
|
| 103 |
+
break
|
| 104 |
+
for row, (state, label) in zip(out[:, prompts.shape[1] :].tolist(), chunk):
|
| 105 |
+
moves = T.decode_solution(row)
|
| 106 |
+
ok = bool(moves) and apply_sequence(state, moves) == SOLVED
|
| 107 |
+
solved += ok
|
| 108 |
+
key = "1-8" if len(label) <= 8 else ("9-14" if len(label) <= 14 else "15+")
|
| 109 |
+
buckets[key][0] += ok
|
| 110 |
+
buckets[key][1] += 1
|
| 111 |
+
model.train()
|
| 112 |
+
parts = " ".join(f"{k}:{buckets[k][0]}/{buckets[k][1]}"
|
| 113 |
+
for k in ("1-8", "9-14", "15+") if buckets[k][1])
|
| 114 |
+
return solved / max(len(rows), 1), parts
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def build_model(args):
|
| 118 |
+
from transformers import LlamaConfig, LlamaForCausalLM
|
| 119 |
+
|
| 120 |
+
cfg = LlamaConfig(
|
| 121 |
+
vocab_size=T.VOCAB_SIZE,
|
| 122 |
+
hidden_size=args.hidden,
|
| 123 |
+
intermediate_size=args.hidden * 4,
|
| 124 |
+
num_hidden_layers=args.layers,
|
| 125 |
+
num_attention_heads=args.heads,
|
| 126 |
+
num_key_value_heads=args.heads,
|
| 127 |
+
max_position_embeddings=T.MAX_SEQ,
|
| 128 |
+
rms_norm_eps=1e-5,
|
| 129 |
+
pad_token_id=T.PAD,
|
| 130 |
+
bos_token_id=T.BOS,
|
| 131 |
+
eos_token_id=T.EOS,
|
| 132 |
+
tie_word_embeddings=True,
|
| 133 |
+
)
|
| 134 |
+
return LlamaForCausalLM(cfg)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def main():
|
| 138 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 139 |
+
p.add_argument("--data", required=True)
|
| 140 |
+
p.add_argument("--val-data", default="", help="Held-out set; defaults to a slice of --data.")
|
| 141 |
+
p.add_argument("--limit", type=int, default=0)
|
| 142 |
+
p.add_argument("--hidden", type=int, default=256)
|
| 143 |
+
p.add_argument("--layers", type=int, default=6)
|
| 144 |
+
p.add_argument("--heads", type=int, default=8)
|
| 145 |
+
p.add_argument("--batch-size", type=int, default=256)
|
| 146 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 147 |
+
p.add_argument("--epochs", type=int, default=1)
|
| 148 |
+
p.add_argument("--max-steps", type=int, default=0)
|
| 149 |
+
p.add_argument("--warmup", type=int, default=200)
|
| 150 |
+
p.add_argument("--eval-every", type=int, default=500)
|
| 151 |
+
p.add_argument("--eval-n", type=int, default=256)
|
| 152 |
+
p.add_argument("--out", default="checkpoints/cube")
|
| 153 |
+
p.add_argument("--hub-repo", default="", help="Push checkpoints here (e.g. user/tiny-cube).")
|
| 154 |
+
p.add_argument("--seed", type=int, default=0)
|
| 155 |
+
p.add_argument("--resume", default="",
|
| 156 |
+
help="Checkpoint to continue from (local dir or Hub repo id).")
|
| 157 |
+
args = p.parse_args()
|
| 158 |
+
|
| 159 |
+
torch.manual_seed(args.seed)
|
| 160 |
+
random.seed(args.seed)
|
| 161 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 162 |
+
|
| 163 |
+
rows = load_jsonl(args.data, args.limit)
|
| 164 |
+
if args.val_data:
|
| 165 |
+
val = load_jsonl(args.val_data, args.eval_n)
|
| 166 |
+
else:
|
| 167 |
+
split = max(len(rows) - args.eval_n, 1)
|
| 168 |
+
rows, val = rows[:split], rows[split:]
|
| 169 |
+
print(f"train {len(rows)} | val {len(val)} | device {device}", flush=True)
|
| 170 |
+
|
| 171 |
+
# Resuming matters on a preemptible box: a reclaimed instance otherwise
|
| 172 |
+
# restarts a multi-hour run from random init. Weights come back from the Hub,
|
| 173 |
+
# which is why checkpoints are pushed there rather than kept only on disk.
|
| 174 |
+
# Optimizer state is not restored -- only the weights -- so the LR schedule
|
| 175 |
+
# restarts; that costs a little progress but keeps the checkpoint portable.
|
| 176 |
+
if args.resume:
|
| 177 |
+
from transformers import LlamaForCausalLM
|
| 178 |
+
model = LlamaForCausalLM.from_pretrained(args.resume).to(device)
|
| 179 |
+
print(f"resumed from {args.resume}", flush=True)
|
| 180 |
+
else:
|
| 181 |
+
model = build_model(args).to(device)
|
| 182 |
+
n_params = sum(p.numel() for p in model.parameters())
|
| 183 |
+
print(f"params {n_params/1e6:.1f}M", flush=True)
|
| 184 |
+
|
| 185 |
+
loader = DataLoader(CubeDataset(rows), batch_size=args.batch_size, shuffle=True,
|
| 186 |
+
collate_fn=collate, drop_last=True, num_workers=2)
|
| 187 |
+
steps = args.max_steps or len(loader) * args.epochs
|
| 188 |
+
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.1, betas=(0.9, 0.95))
|
| 189 |
+
sched = torch.optim.lr_scheduler.LambdaLR(
|
| 190 |
+
opt, lambda s: min((s + 1) / max(args.warmup, 1), 1.0)
|
| 191 |
+
* 0.5 * (1 + math.cos(math.pi * min(s / max(steps, 1), 1.0))))
|
| 192 |
+
|
| 193 |
+
# bf16 needs Ampere or newer. Falling back to fp16 rather than assuming, so a
|
| 194 |
+
# cheaper pre-Ampere box (T4, V100, P100) trains correctly instead of silently
|
| 195 |
+
# producing garbage or refusing to start.
|
| 196 |
+
use_amp = device == "cuda"
|
| 197 |
+
amp_dtype = torch.bfloat16
|
| 198 |
+
if use_amp and not torch.cuda.is_bf16_supported():
|
| 199 |
+
amp_dtype = torch.float16
|
| 200 |
+
print("bf16 unsupported on this GPU; using fp16", flush=True)
|
| 201 |
+
scaler = torch.amp.GradScaler("cuda", enabled=use_amp and amp_dtype is torch.float16)
|
| 202 |
+
Path(args.out).mkdir(parents=True, exist_ok=True)
|
| 203 |
+
step, t0, best = 0, time.time(), -1.0
|
| 204 |
+
done = False
|
| 205 |
+
|
| 206 |
+
while not done:
|
| 207 |
+
for ids, labels, mask in loader:
|
| 208 |
+
ids, labels, mask = ids.to(device), labels.to(device), mask.to(device)
|
| 209 |
+
with torch.autocast("cuda", dtype=amp_dtype, enabled=use_amp):
|
| 210 |
+
loss = model(input_ids=ids, attention_mask=mask, labels=labels).loss
|
| 211 |
+
scaler.scale(loss).backward()
|
| 212 |
+
scaler.unscale_(opt)
|
| 213 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 214 |
+
scaler.step(opt)
|
| 215 |
+
scaler.update()
|
| 216 |
+
sched.step()
|
| 217 |
+
opt.zero_grad(set_to_none=True)
|
| 218 |
+
step += 1
|
| 219 |
+
|
| 220 |
+
if step % 50 == 0:
|
| 221 |
+
print(f"step {step}/{steps} loss {loss.item():.4f} "
|
| 222 |
+
f"lr {sched.get_last_lr()[0]:.2e} {step/(time.time()-t0):.1f} it/s", flush=True)
|
| 223 |
+
|
| 224 |
+
if step % args.eval_every == 0 or step == steps:
|
| 225 |
+
rate, breakdown = solve_rate(model, val, device)
|
| 226 |
+
print(f" step {step} SOLVE RATE {rate:.1%} ({len(val)} held out) "
|
| 227 |
+
f"by solution length: {breakdown}", flush=True)
|
| 228 |
+
# Always keep the latest weights, and additionally keep the best.
|
| 229 |
+
# Saving only on improvement silently threw away 22,000 steps on the
|
| 230 |
+
# first real run: the holdout metric is saturated (see solve_rate),
|
| 231 |
+
# so it peaked mid-run and every later checkpoint -- including the
|
| 232 |
+
# final one -- was discarded. A metric that cannot distinguish two
|
| 233 |
+
# models must not be the thing that chooses between them.
|
| 234 |
+
model.save_pretrained(args.out)
|
| 235 |
+
if rate >= best:
|
| 236 |
+
best = rate
|
| 237 |
+
model.save_pretrained(f"{args.out}-best")
|
| 238 |
+
if args.hub_repo:
|
| 239 |
+
try:
|
| 240 |
+
model.push_to_hub(args.hub_repo, commit_message=f"step {step} solve {rate:.3f}")
|
| 241 |
+
except Exception as e:
|
| 242 |
+
print(f" hub push failed (continuing): {e}", flush=True)
|
| 243 |
+
|
| 244 |
+
if step >= steps:
|
| 245 |
+
done = True
|
| 246 |
+
break
|
| 247 |
+
|
| 248 |
+
print(f"done. best solve rate {best:.1%}. final weights in {args.out}, "
|
| 249 |
+
f"best-scoring in {args.out}-best", flush=True)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
if __name__ == "__main__":
|
| 253 |
+
main()
|
code/train_value.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Trains a value function: cube state -> distance to solved.
|
| 3 |
+
|
| 4 |
+
## Why this and not another policy
|
| 5 |
+
|
| 6 |
+
Four attempts to make a sequence-predicting policy reach past ~8 moves all
|
| 7 |
+
failed (capacity, depth, chunked inference, training on its own rollouts), and
|
| 8 |
+
beam search over that policy raised depth 6 to 94% while leaving depth 15+ at
|
| 9 |
+
exactly zero. Beam search explores only what the policy already ranks highly; it
|
| 10 |
+
has no independent notion of which states are closer to solved, so where the
|
| 11 |
+
policy is wrong there is nothing better to find.
|
| 12 |
+
|
| 13 |
+
A value function supplies that missing compass. This is DeepCubeA's shape
|
| 14 |
+
(Agostinelli et al., Nature MI 2019), which solves the full cube with a network
|
| 15 |
+
of roughly this size by learning distance-to-solved and searching over it.
|
| 16 |
+
|
| 17 |
+
## Why this is cheaper here than in the paper
|
| 18 |
+
|
| 19 |
+
DeepCubeA has no expert, so it bootstraps the value function by approximate
|
| 20 |
+
value iteration -- expensive, and the bulk of its compute. We *do* have an
|
| 21 |
+
expert: Kociemba's solution length is the distance, and every training row
|
| 22 |
+
already carries it. So this is plain supervised learning on labels we generate
|
| 23 |
+
for free.
|
| 24 |
+
|
| 25 |
+
The labels are Kociemba two-phase lengths, which are upper bounds rather than
|
| 26 |
+
true optimal distances. That makes the heuristic inadmissible (A* over it is not
|
| 27 |
+
guaranteed optimal), which does not matter here: the goal is to find *a*
|
| 28 |
+
solution, not the shortest. The lengths were separately confirmed monotonic along
|
| 29 |
+
solution paths, so the signal is consistent.
|
| 30 |
+
|
| 31 |
+
## Classification, not regression
|
| 32 |
+
|
| 33 |
+
Distance is predicted as a distribution over 0..MAX_DIST rather than a scalar.
|
| 34 |
+
Regression to a mean is actively harmful on this task: the state space is
|
| 35 |
+
overwhelmingly distance 18-21, so a squared-error model collapses toward that
|
| 36 |
+
mode and loses exactly the near-solved discrimination the search depends on.
|
| 37 |
+
|
| 38 |
+
**The number to watch is mean absolute error.** Search needs the heuristic to
|
| 39 |
+
rank neighbouring states correctly; at MAE around 1 move that works, and at MAE
|
| 40 |
+
of several moves it cannot, whatever the loss curve says.
|
| 41 |
+
"""
|
| 42 |
+
import argparse, json, math, random, sys, time
|
| 43 |
+
from collections import defaultdict
|
| 44 |
+
from pathlib import Path
|
| 45 |
+
|
| 46 |
+
import torch
|
| 47 |
+
import torch.nn.functional as F
|
| 48 |
+
from torch.utils.data import Dataset, DataLoader
|
| 49 |
+
|
| 50 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 51 |
+
import cube_tokenizer as T
|
| 52 |
+
|
| 53 |
+
MAX_DIST = 26
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class ValueDataset(Dataset):
|
| 57 |
+
def __init__(self, rows):
|
| 58 |
+
self.rows = rows
|
| 59 |
+
|
| 60 |
+
def __len__(self):
|
| 61 |
+
return len(self.rows)
|
| 62 |
+
|
| 63 |
+
def __getitem__(self, i):
|
| 64 |
+
state, dist = self.rows[i]
|
| 65 |
+
return torch.tensor(T.encode_state(state)), torch.tensor(dist)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def load_value_rows(path, limit=0):
|
| 69 |
+
"""Label is the solution length -- the distance -- regardless of how the row
|
| 70 |
+
was generated, so augmented and canonical rows are equally usable."""
|
| 71 |
+
rows = []
|
| 72 |
+
with open(path) as fh:
|
| 73 |
+
for line in fh:
|
| 74 |
+
if limit and len(rows) >= limit:
|
| 75 |
+
break
|
| 76 |
+
r = json.loads(line)
|
| 77 |
+
d = len(r["solution"].split())
|
| 78 |
+
if 0 < d <= MAX_DIST:
|
| 79 |
+
rows.append((r["state"], d))
|
| 80 |
+
return rows
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class ValueNet(torch.nn.Module):
|
| 84 |
+
"""Llama encoder over the 56-token state, mean-pooled, then a distance head."""
|
| 85 |
+
|
| 86 |
+
def __init__(self, hidden, layers, heads):
|
| 87 |
+
super().__init__()
|
| 88 |
+
from transformers import LlamaConfig, LlamaModel
|
| 89 |
+
cfg = LlamaConfig(
|
| 90 |
+
vocab_size=T.VOCAB_SIZE, hidden_size=hidden, intermediate_size=hidden * 4,
|
| 91 |
+
num_hidden_layers=layers, num_attention_heads=heads, num_key_value_heads=heads,
|
| 92 |
+
max_position_embeddings=T.MAX_SEQ, pad_token_id=T.PAD,
|
| 93 |
+
)
|
| 94 |
+
self.encoder = LlamaModel(cfg)
|
| 95 |
+
self.head = torch.nn.Linear(hidden, MAX_DIST + 1)
|
| 96 |
+
|
| 97 |
+
def forward(self, ids):
|
| 98 |
+
h = self.encoder(input_ids=ids).last_hidden_state.mean(dim=1)
|
| 99 |
+
return self.head(h)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@torch.no_grad()
|
| 103 |
+
def evaluate(model, rows, device, batch_size=512):
|
| 104 |
+
"""Reports MAE and exact-match, plus MAE split by distance band.
|
| 105 |
+
|
| 106 |
+
The split matters: an aggregate MAE can look fine while the near-solved band
|
| 107 |
+
-- the only place the heuristic has to be sharp for search to make progress
|
| 108 |
+
-- is useless.
|
| 109 |
+
"""
|
| 110 |
+
model.eval()
|
| 111 |
+
abs_err, exact, n = 0.0, 0, 0
|
| 112 |
+
bands = defaultdict(lambda: [0.0, 0])
|
| 113 |
+
for i in range(0, len(rows), batch_size):
|
| 114 |
+
chunk = rows[i:i + batch_size]
|
| 115 |
+
ids = torch.tensor([T.encode_state(s) for s, _ in chunk], device=device)
|
| 116 |
+
true = torch.tensor([d for _, d in chunk], device=device)
|
| 117 |
+
pred = model(ids).argmax(-1)
|
| 118 |
+
err = (pred - true).abs().float()
|
| 119 |
+
abs_err += err.sum().item()
|
| 120 |
+
exact += (pred == true).sum().item()
|
| 121 |
+
n += len(chunk)
|
| 122 |
+
for e, t in zip(err.tolist(), true.tolist()):
|
| 123 |
+
key = "1-8" if t <= 8 else ("9-14" if t <= 14 else "15+")
|
| 124 |
+
bands[key][0] += e
|
| 125 |
+
bands[key][1] += 1
|
| 126 |
+
model.train()
|
| 127 |
+
parts = " ".join(f"{k}:{bands[k][0]/bands[k][1]:.2f}" for k in ("1-8", "9-14", "15+") if bands[k][1])
|
| 128 |
+
return abs_err / n, exact / n, parts
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def main():
|
| 132 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 133 |
+
p.add_argument("--data", required=True)
|
| 134 |
+
p.add_argument("--val-data", default="")
|
| 135 |
+
p.add_argument("--limit", type=int, default=0)
|
| 136 |
+
p.add_argument("--hidden", type=int, default=512)
|
| 137 |
+
p.add_argument("--layers", type=int, default=8)
|
| 138 |
+
p.add_argument("--heads", type=int, default=8)
|
| 139 |
+
p.add_argument("--batch-size", type=int, default=1024)
|
| 140 |
+
p.add_argument("--lr", type=float, default=6e-4)
|
| 141 |
+
p.add_argument("--max-steps", type=int, default=30000)
|
| 142 |
+
p.add_argument("--warmup", type=int, default=200)
|
| 143 |
+
p.add_argument("--eval-every", type=int, default=1000)
|
| 144 |
+
p.add_argument("--eval-n", type=int, default=4096)
|
| 145 |
+
p.add_argument("--out", default="checkpoints/value")
|
| 146 |
+
p.add_argument("--hub-repo", default="")
|
| 147 |
+
p.add_argument("--seed", type=int, default=0)
|
| 148 |
+
args = p.parse_args()
|
| 149 |
+
|
| 150 |
+
torch.manual_seed(args.seed)
|
| 151 |
+
random.seed(args.seed)
|
| 152 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 153 |
+
|
| 154 |
+
rows = load_value_rows(args.data, args.limit)
|
| 155 |
+
val = load_value_rows(args.val_data, args.eval_n) if args.val_data else rows[-args.eval_n:]
|
| 156 |
+
if not args.val_data:
|
| 157 |
+
rows = rows[:-args.eval_n]
|
| 158 |
+
print(f"train {len(rows)} | val {len(val)} | device {device}", flush=True)
|
| 159 |
+
|
| 160 |
+
model = ValueNet(args.hidden, args.layers, args.heads).to(device)
|
| 161 |
+
print(f"params {sum(q.numel() for q in model.parameters())/1e6:.1f}M", flush=True)
|
| 162 |
+
|
| 163 |
+
loader = DataLoader(ValueDataset(rows), batch_size=args.batch_size, shuffle=True,
|
| 164 |
+
drop_last=True, num_workers=2)
|
| 165 |
+
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.1, betas=(0.9, 0.95))
|
| 166 |
+
sched = torch.optim.lr_scheduler.LambdaLR(
|
| 167 |
+
opt, lambda s: min((s + 1) / max(args.warmup, 1), 1.0)
|
| 168 |
+
* 0.5 * (1 + math.cos(math.pi * min(s / max(args.max_steps, 1), 1.0))))
|
| 169 |
+
use_amp = device == "cuda"
|
| 170 |
+
amp_dtype = torch.bfloat16 if (use_amp and torch.cuda.is_bf16_supported()) else torch.float16
|
| 171 |
+
scaler = torch.amp.GradScaler("cuda", enabled=use_amp and amp_dtype is torch.float16)
|
| 172 |
+
|
| 173 |
+
Path(args.out).mkdir(parents=True, exist_ok=True)
|
| 174 |
+
step, t0, best = 0, time.time(), 1e9
|
| 175 |
+
while step < args.max_steps:
|
| 176 |
+
for ids, dist in loader:
|
| 177 |
+
ids, dist = ids.to(device), dist.to(device)
|
| 178 |
+
with torch.autocast("cuda", dtype=amp_dtype, enabled=use_amp):
|
| 179 |
+
loss = F.cross_entropy(model(ids), dist)
|
| 180 |
+
scaler.scale(loss).backward()
|
| 181 |
+
scaler.unscale_(opt)
|
| 182 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
| 183 |
+
scaler.step(opt)
|
| 184 |
+
scaler.update()
|
| 185 |
+
sched.step()
|
| 186 |
+
opt.zero_grad(set_to_none=True)
|
| 187 |
+
step += 1
|
| 188 |
+
if step % 100 == 0:
|
| 189 |
+
print(f"step {step}/{args.max_steps} loss {loss.item():.4f} "
|
| 190 |
+
f"{step/(time.time()-t0):.1f} it/s", flush=True)
|
| 191 |
+
if step % args.eval_every == 0 or step >= args.max_steps:
|
| 192 |
+
mae, exact, parts = evaluate(model, val, device)
|
| 193 |
+
print(f" step {step} MAE {mae:.3f} moves exact {exact:.1%} "
|
| 194 |
+
f"by true distance: {parts}", flush=True)
|
| 195 |
+
torch.save({"state_dict": model.state_dict(),
|
| 196 |
+
"hidden": args.hidden, "layers": args.layers,
|
| 197 |
+
"heads": args.heads}, f"{args.out}/value.pt")
|
| 198 |
+
if mae < best:
|
| 199 |
+
best = mae
|
| 200 |
+
torch.save({"state_dict": model.state_dict(),
|
| 201 |
+
"hidden": args.hidden, "layers": args.layers,
|
| 202 |
+
"heads": args.heads}, f"{args.out}/value-best.pt")
|
| 203 |
+
if args.hub_repo:
|
| 204 |
+
try:
|
| 205 |
+
from huggingface_hub import HfApi
|
| 206 |
+
HfApi().upload_file(path_or_fileobj=f"{args.out}/value.pt",
|
| 207 |
+
path_in_repo="value.pt", repo_id=args.hub_repo)
|
| 208 |
+
except Exception as e:
|
| 209 |
+
print(f" hub push failed (continuing): {e}", flush=True)
|
| 210 |
+
if step >= args.max_steps:
|
| 211 |
+
break
|
| 212 |
+
print(f"done. best MAE {best:.3f} moves. weights in {args.out}", flush=True)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
if __name__ == "__main__":
|
| 216 |
+
main()
|
code/train_value_iteration.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Trains a value function by approximate value iteration -- no solver in the loop.
|
| 3 |
+
|
| 4 |
+
## Why this replaces the supervised version
|
| 5 |
+
|
| 6 |
+
`train_value.py` learns distance-to-solved from Kociemba's solution lengths.
|
| 7 |
+
That reached MAE 1.333 and, with beam search, solved canonical depth 10 at 100%
|
| 8 |
+
-- but depth 15/20 stayed at 28%/16%, and the reason turned out to be the
|
| 9 |
+
labels, not the model:
|
| 10 |
+
|
| 11 |
+
Kociemba length on uniform random states: 47% are exactly 21, 79% in 20-22.
|
| 12 |
+
|
| 13 |
+
They are two-phase *upper bounds*, and they cluster. Above distance ~15 there is
|
| 14 |
+
almost no label variance to learn from, so nothing can learn to tell a
|
| 15 |
+
distance-19 state from a distance-21 one. Measured directly: in the 15+ band
|
| 16 |
+
(85.5% of that holdout) the supervised model scored MAE 1.44 against 0.57 for a
|
| 17 |
+
constant that always answers 21 -- 2.5x worse than ignoring the cube. Search
|
| 18 |
+
worked where the heuristic was informative and wandered where it was not.
|
| 19 |
+
|
| 20 |
+
More capacity, steps or data cannot fix a label that carries no information.
|
| 21 |
+
|
| 22 |
+
## The method (DeepCubeA's DAVI, Agostinelli et al., Nature MI 2019)
|
| 23 |
+
|
| 24 |
+
Bootstrap the targets instead of labelling them. For a state s, its cost-to-go
|
| 25 |
+
is one move plus the best of its successors:
|
| 26 |
+
|
| 27 |
+
J(s) = 0 if s is solved
|
| 28 |
+
J(s) = min over a of (1 + J_target(s')) otherwise
|
| 29 |
+
|
| 30 |
+
Start from a randomly initialised net and iterate. Accuracy radiates outward
|
| 31 |
+
from the solved state: once distance-1 states are right, distance-2 states
|
| 32 |
+
become learnable, and so on. The targets are real distances the net discovers,
|
| 33 |
+
not a solver's upper bounds, so they stay informative at every distance -- which
|
| 34 |
+
is precisely what the supervised labels stopped being past 15.
|
| 35 |
+
|
| 36 |
+
Two details that make it converge rather than chase itself:
|
| 37 |
+
|
| 38 |
+
- **A target network.** Targets come from a frozen copy, refreshed only when the
|
| 39 |
+
training loss falls below `--update-threshold`. Bootstrapping against the
|
| 40 |
+
live net is the standard way this diverges.
|
| 41 |
+
- **Regression, not classification.** `train_value.py` argues for
|
| 42 |
+
classification because a squared-error model collapses toward the 18-21 mode
|
| 43 |
+
of the *Kociemba label* distribution. That argument does not transfer: these
|
| 44 |
+
targets are bootstrapped and spread across the whole range, and DAVI's `min`
|
| 45 |
+
needs a scalar it can compare and add 1 to.
|
| 46 |
+
|
| 47 |
+
## What to watch
|
| 48 |
+
|
| 49 |
+
Not the loss -- it is measured against a moving target, so it says little about
|
| 50 |
+
quality. **Watch the greedy solve rate**, reported per canonical depth bucket.
|
| 51 |
+
That is the heuristic doing the actual job: descend to the lowest-valued child
|
| 52 |
+
each step and see whether it reaches solved. It needs no labels, which is the
|
| 53 |
+
point, since out here there is no ground-truth distance to compare against.
|
| 54 |
+
"""
|
| 55 |
+
import argparse, json, sys, time
|
| 56 |
+
from pathlib import Path
|
| 57 |
+
|
| 58 |
+
import numpy as np
|
| 59 |
+
import torch
|
| 60 |
+
import torch.nn.functional as F
|
| 61 |
+
|
| 62 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 63 |
+
import cube_ops as C
|
| 64 |
+
import cube_tokenizer as T
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class ValueNet(torch.nn.Module):
|
| 68 |
+
"""Llama encoder over the 54-facelet state, mean-pooled, to one scalar."""
|
| 69 |
+
|
| 70 |
+
def __init__(self, hidden, layers, heads):
|
| 71 |
+
super().__init__()
|
| 72 |
+
from transformers import LlamaConfig, LlamaModel
|
| 73 |
+
cfg = LlamaConfig(
|
| 74 |
+
vocab_size=T.VOCAB_SIZE, hidden_size=hidden, intermediate_size=hidden * 4,
|
| 75 |
+
num_hidden_layers=layers, num_attention_heads=heads,
|
| 76 |
+
num_key_value_heads=heads, max_position_embeddings=T.MAX_SEQ,
|
| 77 |
+
pad_token_id=T.PAD,
|
| 78 |
+
)
|
| 79 |
+
self.encoder = LlamaModel(cfg)
|
| 80 |
+
self.head = torch.nn.Linear(hidden, 1)
|
| 81 |
+
|
| 82 |
+
def forward(self, ids):
|
| 83 |
+
h = self.encoder(input_ids=ids).last_hidden_state.mean(dim=1)
|
| 84 |
+
return self.head(h).squeeze(-1)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# Colour-index -> token-id lookup, taken from the tokenizer so the array path and
|
| 88 |
+
# T.encode_state produce byte-identical sequences (verified in tests).
|
| 89 |
+
_COLOR_IDS = np.array(T.COLOR_IDS, dtype=np.int64)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# The tokenizer's encode_state wraps a state in BOS/SEP for the seq2seq policy.
|
| 93 |
+
# Reused unchanged so a state has one encoding across every model here.
|
| 94 |
+
def encode_batch(states: np.ndarray, device):
|
| 95 |
+
"""(N, 54) uint8 facelet indices -> (N, MAX) token ids on `device`."""
|
| 96 |
+
toks = _COLOR_IDS[states.astype(np.int64)]
|
| 97 |
+
bos = np.full((len(states), 1), T.BOS, dtype=np.int64)
|
| 98 |
+
sep = np.full((len(states), 1), T.SEP, dtype=np.int64)
|
| 99 |
+
return torch.from_numpy(np.concatenate([bos, toks, sep], axis=1)).to(device)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@torch.no_grad()
|
| 103 |
+
def value_of(net, states: np.ndarray, device, batch_size=4096, amp=True):
|
| 104 |
+
"""Scalar value per state, in eval mode, batched."""
|
| 105 |
+
net.eval()
|
| 106 |
+
out = np.empty(len(states), dtype=np.float32)
|
| 107 |
+
for i in range(0, len(states), batch_size):
|
| 108 |
+
chunk = states[i:i + batch_size]
|
| 109 |
+
ids = encode_batch(chunk, device)
|
| 110 |
+
with torch.autocast("cuda", dtype=torch.bfloat16, enabled=amp and device == "cuda"):
|
| 111 |
+
v = net(ids)
|
| 112 |
+
out[i:i + len(chunk)] = v.float().cpu().numpy()
|
| 113 |
+
net.train()
|
| 114 |
+
# A solved state is worth 0 by definition, never by prediction: the search
|
| 115 |
+
# and the targets both lean on that being exact.
|
| 116 |
+
out[C.is_solved(states)] = 0.0
|
| 117 |
+
return np.maximum(out, 0.0)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def davi_targets(target_net, states: np.ndarray, device, amp=True):
|
| 121 |
+
"""J(s) = min over the 18 moves of (1 + J_target(s')), and 0 where s is solved."""
|
| 122 |
+
kids = C.all_children(states) # (N, 18, 54)
|
| 123 |
+
flat = kids.reshape(-1, 54)
|
| 124 |
+
v = value_of(target_net, flat, device, amp=amp).reshape(len(states), C.NUM_MOVES)
|
| 125 |
+
# A move onto the solved state costs exactly 1 and nothing more, regardless
|
| 126 |
+
# of what the net thinks of the solved state.
|
| 127 |
+
solved_kid = C.is_solved(flat).reshape(len(states), C.NUM_MOVES)
|
| 128 |
+
v = np.where(solved_kid, 0.0, v)
|
| 129 |
+
tgt = (1.0 + v).min(axis=1)
|
| 130 |
+
tgt[C.is_solved(states)] = 0.0
|
| 131 |
+
return tgt.astype(np.float32)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@torch.no_grad()
|
| 135 |
+
def greedy_solve_rate(net, device, depths, n_per_depth, rng, max_steps=40, amp=True):
|
| 136 |
+
"""Fraction of scrambles solved by descending to the lowest-valued child.
|
| 137 |
+
|
| 138 |
+
The honest metric for this trainer: no labels involved, and it is the
|
| 139 |
+
heuristic doing the job search will ask of it. Every scramble at every depth
|
| 140 |
+
is stepped in one batch, so this costs a handful of forward passes rather
|
| 141 |
+
than one per puzzle.
|
| 142 |
+
"""
|
| 143 |
+
out = {}
|
| 144 |
+
for d in depths:
|
| 145 |
+
states = _walk_exact(n_per_depth, d, rng)
|
| 146 |
+
done = C.is_solved(states)
|
| 147 |
+
for _ in range(max_steps):
|
| 148 |
+
live = np.where(~done)[0]
|
| 149 |
+
if not len(live):
|
| 150 |
+
break
|
| 151 |
+
kids = C.all_children(states[live])
|
| 152 |
+
flat = kids.reshape(-1, 54)
|
| 153 |
+
v = value_of(net, flat, device, amp=amp).reshape(len(live), C.NUM_MOVES)
|
| 154 |
+
best = v.argmin(axis=1)
|
| 155 |
+
states[live] = kids[np.arange(len(live)), best]
|
| 156 |
+
done[live] = C.is_solved(states[live])
|
| 157 |
+
out[d] = float(done.mean())
|
| 158 |
+
return out
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _walk_exact(n, depth, rng):
|
| 162 |
+
"""n states each from a walk of exactly `depth` moves."""
|
| 163 |
+
states = np.repeat(C.SOLVED_ARR[None, :], n, axis=0)
|
| 164 |
+
prev = np.full(n, -1)
|
| 165 |
+
for _ in range(depth):
|
| 166 |
+
mv = rng.integers(0, C.NUM_MOVES, size=n)
|
| 167 |
+
clash = (mv // 3) == prev
|
| 168 |
+
while clash.any():
|
| 169 |
+
mv[clash] = rng.integers(0, C.NUM_MOVES, size=int(clash.sum()))
|
| 170 |
+
clash = (mv // 3) == prev
|
| 171 |
+
states = states[np.arange(n)[:, None], C.PERMS[mv]]
|
| 172 |
+
prev = mv // 3
|
| 173 |
+
return states
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def main():
|
| 177 |
+
p = argparse.ArgumentParser(description=__doc__,
|
| 178 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 179 |
+
p.add_argument("--hidden", type=int, default=512)
|
| 180 |
+
p.add_argument("--layers", type=int, default=8)
|
| 181 |
+
p.add_argument("--heads", type=int, default=8)
|
| 182 |
+
p.add_argument("--batch-size", type=int, default=1024)
|
| 183 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 184 |
+
p.add_argument("--max-steps", type=int, default=100000)
|
| 185 |
+
p.add_argument("--scramble-depth", type=int, default=30,
|
| 186 |
+
help="States are sampled from walks of 1..this many moves. Past "
|
| 187 |
+
"~20 every state is fully mixed (God's Number), so a larger "
|
| 188 |
+
"value buys nothing.")
|
| 189 |
+
p.add_argument("--update-threshold", type=float, default=0.05,
|
| 190 |
+
help="Refresh the target network once training loss falls below "
|
| 191 |
+
"this. Lower is more stable and slower.")
|
| 192 |
+
p.add_argument("--min-update-steps", type=int, default=500,
|
| 193 |
+
help="Never refresh the target network more often than this, so "
|
| 194 |
+
"an early lucky batch cannot ratchet it forward.")
|
| 195 |
+
p.add_argument("--eval-every", type=int, default=2000)
|
| 196 |
+
p.add_argument("--eval-n", type=int, default=200)
|
| 197 |
+
p.add_argument("--eval-depths", default="3,6,10,15,20")
|
| 198 |
+
p.add_argument("--out", default="checkpoints/value_iter")
|
| 199 |
+
p.add_argument("--hub-repo", default="")
|
| 200 |
+
p.add_argument("--upload-every", type=int, default=1,
|
| 201 |
+
help="Push to the Hub every N evals that improve on the best "
|
| 202 |
+
"mean greedy solve rate. The point is that a run can be "
|
| 203 |
+
"stopped the moment its frontier plateaus without losing "
|
| 204 |
+
"the model -- an end-of-run-only upload makes stopping "
|
| 205 |
+
"early cost the whole run.")
|
| 206 |
+
p.add_argument("--seed", type=int, default=0)
|
| 207 |
+
args = p.parse_args()
|
| 208 |
+
|
| 209 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 210 |
+
amp = device == "cuda"
|
| 211 |
+
torch.manual_seed(args.seed)
|
| 212 |
+
rng = np.random.default_rng(args.seed)
|
| 213 |
+
|
| 214 |
+
net = ValueNet(args.hidden, args.layers, args.heads).to(device)
|
| 215 |
+
target_net = ValueNet(args.hidden, args.layers, args.heads).to(device)
|
| 216 |
+
target_net.load_state_dict(net.state_dict())
|
| 217 |
+
for q in target_net.parameters():
|
| 218 |
+
q.requires_grad_(False)
|
| 219 |
+
|
| 220 |
+
params = sum(q.numel() for q in net.parameters())
|
| 221 |
+
print(f"value-iteration net: {params/1e6:.1f}M params | {device} | "
|
| 222 |
+
f"batch {args.batch_size} | walk depth 1..{args.scramble_depth}", flush=True)
|
| 223 |
+
|
| 224 |
+
opt = torch.optim.AdamW(net.parameters(), lr=args.lr)
|
| 225 |
+
eval_depths = [int(d) for d in args.eval_depths.split(",")]
|
| 226 |
+
out_dir = Path(args.out)
|
| 227 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 228 |
+
|
| 229 |
+
api = None
|
| 230 |
+
if args.hub_repo:
|
| 231 |
+
import os
|
| 232 |
+
from huggingface_hub import HfApi
|
| 233 |
+
api = HfApi(token=os.environ.get("HF_TOKEN"))
|
| 234 |
+
|
| 235 |
+
def publish(tag):
|
| 236 |
+
if api is None:
|
| 237 |
+
return
|
| 238 |
+
try:
|
| 239 |
+
api.upload_folder(repo_id=args.hub_repo, folder_path=str(out_dir),
|
| 240 |
+
path_in_repo="checkpoints/value_iter")
|
| 241 |
+
print(f" uploaded ({tag})", flush=True)
|
| 242 |
+
except Exception as e:
|
| 243 |
+
# A transient Hub failure must not kill a run that is otherwise fine;
|
| 244 |
+
# the next improvement retries, and the local copy is still on disk.
|
| 245 |
+
print(f" upload failed ({tag}): {type(e).__name__}: {e}", flush=True)
|
| 246 |
+
|
| 247 |
+
best = -1.0
|
| 248 |
+
improved = 0
|
| 249 |
+
updates = 0
|
| 250 |
+
last_update = 0
|
| 251 |
+
run = []
|
| 252 |
+
t0 = time.time()
|
| 253 |
+
for step in range(1, args.max_steps + 1):
|
| 254 |
+
states, _ = C.random_walk(args.batch_size, args.scramble_depth, rng)
|
| 255 |
+
tgt = davi_targets(target_net, states, device, amp=amp)
|
| 256 |
+
|
| 257 |
+
ids = encode_batch(states, device)
|
| 258 |
+
y = torch.from_numpy(tgt).to(device)
|
| 259 |
+
with torch.autocast("cuda", dtype=torch.bfloat16, enabled=amp):
|
| 260 |
+
pred = net(ids)
|
| 261 |
+
loss = F.mse_loss(pred.float(), y)
|
| 262 |
+
opt.zero_grad(set_to_none=True)
|
| 263 |
+
loss.backward()
|
| 264 |
+
torch.nn.utils.clip_grad_norm_(net.parameters(), 1.0)
|
| 265 |
+
opt.step()
|
| 266 |
+
|
| 267 |
+
l = loss.item()
|
| 268 |
+
if l < args.update_threshold and step - last_update >= args.min_update_steps:
|
| 269 |
+
target_net.load_state_dict(net.state_dict())
|
| 270 |
+
updates += 1
|
| 271 |
+
last_update = step
|
| 272 |
+
print(f" step {step}: target net updated (#{updates}, loss {l:.4f})",
|
| 273 |
+
flush=True)
|
| 274 |
+
|
| 275 |
+
if step % 100 == 0:
|
| 276 |
+
print(f"step {step}/{args.max_steps} loss {l:.4f} "
|
| 277 |
+
f"tgt_updates {updates} {step/(time.time()-t0):.1f} it/s", flush=True)
|
| 278 |
+
|
| 279 |
+
if step % args.eval_every == 0 or step == args.max_steps:
|
| 280 |
+
rates = greedy_solve_rate(net, device, eval_depths, args.eval_n,
|
| 281 |
+
np.random.default_rng(12345), amp=amp)
|
| 282 |
+
parts = " ".join(f"d{d}:{r:.0%}" for d, r in rates.items())
|
| 283 |
+
mean = float(np.mean(list(rates.values())))
|
| 284 |
+
print(f" step {step} greedy solve {parts} | mean {mean:.1%}", flush=True)
|
| 285 |
+
run.append({"step": step, "loss": l, "target_updates": updates,
|
| 286 |
+
"greedy": rates, "mean": mean})
|
| 287 |
+
torch.save({"model": net.state_dict(), "hidden": args.hidden,
|
| 288 |
+
"layers": args.layers, "heads": args.heads, "step": step},
|
| 289 |
+
out_dir / "value_iter.pt")
|
| 290 |
+
(out_dir / "history.json").write_text(json.dumps(run, indent=2))
|
| 291 |
+
if mean > best:
|
| 292 |
+
best = mean
|
| 293 |
+
improved += 1
|
| 294 |
+
torch.save({"model": net.state_dict(), "hidden": args.hidden,
|
| 295 |
+
"layers": args.layers, "heads": args.heads, "step": step},
|
| 296 |
+
out_dir / "value_iter_best.pt")
|
| 297 |
+
if improved % args.upload_every == 0:
|
| 298 |
+
publish(f"step {step}, mean {mean:.1%}")
|
| 299 |
+
|
| 300 |
+
print(f"done. best mean greedy solve rate {best:.1%}. weights in {out_dir}",
|
| 301 |
+
flush=True)
|
| 302 |
+
|
| 303 |
+
publish("final")
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
if __name__ == "__main__":
|
| 307 |
+
main()
|
code/vast_bootstrap.sh
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Provisions a vast.ai instance and runs the full pipeline: generate -> verify ->
|
| 3 |
+
# train -> push to the Hub. Written to be re-runnable: a reclaimed instance can be
|
| 4 |
+
# replaced and this script started again from scratch.
|
| 5 |
+
#
|
| 6 |
+
# Expects, in the environment:
|
| 7 |
+
# HF_TOKEN write token, for checkpoint pushes
|
| 8 |
+
# HUB_REPO e.g. yourname/tiny-cube-solver
|
| 9 |
+
# Optional: PAIRS (default 10000000), STEPS, HIDDEN, LAYERS
|
| 10 |
+
set -euo pipefail
|
| 11 |
+
|
| 12 |
+
PAIRS="${PAIRS:-10000000}"
|
| 13 |
+
STEPS="${STEPS:-40000}"
|
| 14 |
+
HIDDEN="${HIDDEN:-384}"
|
| 15 |
+
LAYERS="${LAYERS:-8}"
|
| 16 |
+
CORES="$(nproc)"
|
| 17 |
+
|
| 18 |
+
echo "=== $CORES cores, $(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || echo 'no GPU') ==="
|
| 19 |
+
|
| 20 |
+
# Build against SYSTEM python, not the image's conda python. kociemba ships a C
|
| 21 |
+
# extension and a pure-Python fallback, and it falls back *silently* when the
|
| 22 |
+
# extension cannot build -- which is what happens if the venv's interpreter has no
|
| 23 |
+
# matching dev headers. python3-dev is therefore not optional here.
|
| 24 |
+
apt-get update -qq
|
| 25 |
+
# libffi-dev is the one that actually matters and is the least obvious: kociemba's
|
| 26 |
+
# native extension is built through cffi, which needs ffi.h. Without it the
|
| 27 |
+
# compile dies with "fatal error: ffi.h: No such file or directory", kociemba
|
| 28 |
+
# installs its pure-Python fallback instead, and pip still exits 0.
|
| 29 |
+
apt-get install -y -qq build-essential python3-dev python3-venv libffi-dev git
|
| 30 |
+
# Prefer python3.11. kociemba 1.2.1 builds its C extension cleanly on 3.11 (the
|
| 31 |
+
# version this repo is developed against) but silently produces no extension on
|
| 32 |
+
# the 3.10 that ships as system python in the Ubuntu 22.04 based images -- pip
|
| 33 |
+
# still reports success, so the only symptom is the fallback. The PyTorch images
|
| 34 |
+
# carry a conda 3.11, so prefer that and fall back to system python.
|
| 35 |
+
PYBIN=""
|
| 36 |
+
for cand in /opt/conda/bin/python3.11 /opt/conda/bin/python "$(command -v python3.11 || true)" /usr/bin/python3; do
|
| 37 |
+
[ -x "$cand" ] || continue
|
| 38 |
+
if "$cand" -c 'import sys; sys.exit(0 if sys.version_info[:2] >= (3,11) else 1)' 2>/dev/null; then
|
| 39 |
+
PYBIN="$cand"; break
|
| 40 |
+
fi
|
| 41 |
+
done
|
| 42 |
+
[ -z "$PYBIN" ] && PYBIN=/usr/bin/python3
|
| 43 |
+
echo "building venv from $PYBIN ($($PYBIN -V 2>&1))"
|
| 44 |
+
"$PYBIN" -m venv /opt/venv
|
| 45 |
+
/opt/venv/bin/pip install -q --upgrade pip
|
| 46 |
+
# kociemba's setup.py predates modern setuptools' removal of install_layout.
|
| 47 |
+
/opt/venv/bin/pip install -q "setuptools<60" wheel
|
| 48 |
+
# Full output, not a tail: when the C extension fails to build, kociemba still
|
| 49 |
+
# installs successfully (as pure Python), so the compiler error is the only
|
| 50 |
+
# evidence of what went wrong and it must not be truncated away.
|
| 51 |
+
/opt/venv/bin/pip install --no-build-isolation kociemba 2>&1 | tail -40
|
| 52 |
+
|
| 53 |
+
# Last resort before giving up: build from the upstream git source, whose build
|
| 54 |
+
# metadata is newer than the 2017 PyPI sdist.
|
| 55 |
+
if ! /opt/venv/bin/python -c "import kociemba.ckociembawrapper" 2>/dev/null; then
|
| 56 |
+
echo "=== sdist build produced no extension; trying upstream git ==="
|
| 57 |
+
/opt/venv/bin/pip install --force-reinstall --no-build-isolation \
|
| 58 |
+
"git+https://github.com/muodov/kociemba.git" 2>&1 | tail -15 || true
|
| 59 |
+
fi
|
| 60 |
+
|
| 61 |
+
if ! /opt/venv/bin/python -c "import kociemba.ckociembawrapper" 2>/dev/null; then
|
| 62 |
+
echo "=== NATIVE BUILD FAILED -- diagnostics ==="
|
| 63 |
+
echo "python: $(/opt/venv/bin/python -V 2>&1) at $(/opt/venv/bin/python -c 'import sys;print(sys.executable)')"
|
| 64 |
+
echo "gcc: $(gcc --version 2>&1 | head -1)"
|
| 65 |
+
echo "includes: $(/opt/venv/bin/python -c 'import sysconfig;print(sysconfig.get_paths()["include"])')"
|
| 66 |
+
ls -l "$(/opt/venv/bin/python -c 'import sysconfig;print(sysconfig.get_paths()["include"])')/Python.h" 2>&1 || echo " Python.h MISSING"
|
| 67 |
+
dpkg -l | grep -E "python3.*dev|build-essential" || echo " no dev packages found"
|
| 68 |
+
echo "=== retrying verbosely ==="
|
| 69 |
+
/opt/venv/bin/pip install --force-reinstall --no-build-isolation -v kociemba 2>&1 | grep -iE "error|gcc|Python.h|fatal" | head -30
|
| 70 |
+
fi
|
| 71 |
+
/opt/venv/bin/pip install -q torch --index-url https://download.pytorch.org/whl/cu128
|
| 72 |
+
/opt/venv/bin/pip install -q transformers huggingface_hub
|
| 73 |
+
|
| 74 |
+
# Search evaluation only: skip data generation and training entirely, download a
|
| 75 |
+
# trained value model and score the state search on the canonical scrambles. The
|
| 76 |
+
# search needs a GPU -- each solve evaluates beam*18 children per step -- so it
|
| 77 |
+
# cannot be done from the session container.
|
| 78 |
+
if [ -n "${EVAL_VALUE_ONLY:-}" ]; then
|
| 79 |
+
cd "${CODE_DIR:-/workspace/code}"
|
| 80 |
+
echo "=== evaluating value search from ${EVAL_VALUE_ONLY} ==="
|
| 81 |
+
/opt/venv/bin/python eval_value_search.py --value "$EVAL_VALUE_ONLY" \
|
| 82 |
+
--beam "${SEARCH_BEAM:-64}" --limit-per-depth "${SEARCH_N:-25}" \
|
| 83 |
+
${SEARCH_DEPTHS:+--depths "$SEARCH_DEPTHS"} ${SEARCH_LAM:+--lam "$SEARCH_LAM"}
|
| 84 |
+
echo "=== done ==="
|
| 85 |
+
exit 0
|
| 86 |
+
fi
|
| 87 |
+
|
| 88 |
+
# Value iteration needs no dataset and no solver: it derives its own targets by
|
| 89 |
+
# bootstrapping from the solved state. So this mode skips both the kociemba gate
|
| 90 |
+
# and the multi-million-pair generation that dominates every other run's startup
|
| 91 |
+
# -- it goes straight from a GPU check to training.
|
| 92 |
+
if [ -n "${TRAIN_VALUE_ITER:-}" ]; then
|
| 93 |
+
cd "${CODE_DIR:-/workspace/code}"
|
| 94 |
+
mkdir -p checkpoints
|
| 95 |
+
echo "=== GPU check ==="
|
| 96 |
+
/opt/venv/bin/python - <<'PYVI'
|
| 97 |
+
import sys, torch
|
| 98 |
+
if not torch.cuda.is_available():
|
| 99 |
+
sys.exit("ABORT: no CUDA device")
|
| 100 |
+
print(f"gpu: {torch.cuda.get_device_name(0)} sm_{''.join(map(str,torch.cuda.get_device_capability(0)))} "
|
| 101 |
+
f"{torch.cuda.get_device_properties(0).total_memory/1e9:.0f}GB | torch {torch.__version__}")
|
| 102 |
+
PYVI
|
| 103 |
+
echo "=== training value function by value iteration ==="
|
| 104 |
+
/opt/venv/bin/python train_value_iteration.py \
|
| 105 |
+
--hidden "${HIDDEN:-512}" --layers "${LAYERS:-8}" --heads 8 \
|
| 106 |
+
--batch-size "${BATCH:-1024}" --lr "${LR:-3e-4}" --max-steps "${STEPS:-30000}" \
|
| 107 |
+
--scramble-depth "${WALK_DEPTH:-30}" \
|
| 108 |
+
--update-threshold "${UPDATE_THRESHOLD:-0.05}" \
|
| 109 |
+
--min-update-steps "${MIN_UPDATE_STEPS:-1000}" \
|
| 110 |
+
--eval-every "${EVAL_EVERY:-1000}" --eval-n "${EVAL_N:-200}" \
|
| 111 |
+
--out checkpoints/value_iter --hub-repo "${HUB_REPO:-}"
|
| 112 |
+
echo "=== done ==="
|
| 113 |
+
exit 0
|
| 114 |
+
fi
|
| 115 |
+
|
| 116 |
+
# Hard gate. The pure-Python fallback is ~50x slower, so generating this dataset
|
| 117 |
+
# with it would take days rather than hours -- and the only symptom is a warning
|
| 118 |
+
# buried in the log while the meter runs. Fail here instead.
|
| 119 |
+
echo "=== verifying native kociemba ==="
|
| 120 |
+
/opt/venv/bin/python - <<'PYCHECK'
|
| 121 |
+
import sys, time
|
| 122 |
+
import kociemba
|
| 123 |
+
from kociemba import ckociembawrapper # absent entirely when the C build failed
|
| 124 |
+
SOLVED = "".join("URFDLB"[i // 9] for i in range(54))
|
| 125 |
+
kociemba.solve("DRLUUBFBRBLURRLRUBLRDDFDLFUFUFFDBRDUBRUFLLFDDBFLUBLRBD")
|
| 126 |
+
t = time.time()
|
| 127 |
+
for _ in range(20):
|
| 128 |
+
kociemba.solve("DRLUUBFBRBLURRLRUBLRDDFDLFUFUFFDBRDUBRUFLLFDDBFLUBLRBD")
|
| 129 |
+
ms = (time.time() - t) / 20 * 1000
|
| 130 |
+
print(f"kociemba: {ms:.1f} ms/solve (native)")
|
| 131 |
+
if ms > 200:
|
| 132 |
+
sys.exit(f"ABORT: {ms:.0f} ms/solve is fallback-speed; refusing to generate")
|
| 133 |
+
PYCHECK
|
| 134 |
+
|
| 135 |
+
cd "${CODE_DIR:-/workspace/code}"
|
| 136 |
+
mkdir -p data checkpoints
|
| 137 |
+
|
| 138 |
+
# Smoke-test the GPU *before* generating anything. A torch build without kernels
|
| 139 |
+
# for this GPU fails only when the first tensor op runs, which used to be training
|
| 140 |
+
# -- so an unusable GPU was discovered an hour and a full dataset later. This
|
| 141 |
+
# runs the real model for one forward and backward pass, so it catches missing
|
| 142 |
+
# kernels rather than merely that a device is visible.
|
| 143 |
+
echo "=== verifying GPU can run the model ==="
|
| 144 |
+
/opt/venv/bin/python - <<PYCHECK
|
| 145 |
+
import sys, torch
|
| 146 |
+
if not torch.cuda.is_available():
|
| 147 |
+
sys.exit("ABORT: no CUDA device visible")
|
| 148 |
+
name = torch.cuda.get_device_name(0)
|
| 149 |
+
cap = torch.cuda.get_device_capability(0)
|
| 150 |
+
vram = torch.cuda.get_device_properties(0).total_memory / 1e9
|
| 151 |
+
print(f"gpu: {name} sm_{cap[0]}{cap[1]} {vram:.0f}GB | torch {torch.__version__} | arch {torch.cuda.get_arch_list()}")
|
| 152 |
+
try:
|
| 153 |
+
from transformers import LlamaConfig, LlamaForCausalLM
|
| 154 |
+
# The REAL config and batch size. A scaled-down probe is worse than useless
|
| 155 |
+
# here: it passes on a GPU that cannot hold the actual model, so the OOM
|
| 156 |
+
# surfaces only after data generation has already been paid for.
|
| 157 |
+
cfg = LlamaConfig(vocab_size=28, hidden_size=$HIDDEN, intermediate_size=$HIDDEN * 4,
|
| 158 |
+
num_hidden_layers=$LAYERS, num_attention_heads=8,
|
| 159 |
+
num_key_value_heads=8, max_position_embeddings=83,
|
| 160 |
+
tie_word_embeddings=True)
|
| 161 |
+
m = LlamaForCausalLM(cfg).cuda()
|
| 162 |
+
opt = torch.optim.AdamW(m.parameters(), lr=1e-4)
|
| 163 |
+
ids = torch.randint(0, 28, ($BATCH, 83), device="cuda")
|
| 164 |
+
with torch.autocast("cuda", dtype=torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16):
|
| 165 |
+
loss = m(input_ids=ids, labels=ids).loss
|
| 166 |
+
loss.backward()
|
| 167 |
+
opt.step() # optimizer state is where the memory actually peaks
|
| 168 |
+
torch.cuda.synchronize()
|
| 169 |
+
peak = torch.cuda.max_memory_allocated() / 1e9
|
| 170 |
+
print(f"gpu smoke test: full step at batch $BATCH OK, peak {peak:.1f}GB of {vram:.0f}GB")
|
| 171 |
+
except torch.OutOfMemoryError:
|
| 172 |
+
sys.exit(f"ABORT: batch $BATCH of a $LAYERS-layer/$HIDDEN-wide model does not fit in {vram:.0f}GB")
|
| 173 |
+
except Exception as e:
|
| 174 |
+
sys.exit(f"ABORT: GPU cannot run the model ({type(e).__name__}: {e})")
|
| 175 |
+
PYCHECK
|
| 176 |
+
|
| 177 |
+
cd "${CODE_DIR:-/workspace/code}"
|
| 178 |
+
mkdir -p data checkpoints
|
| 179 |
+
|
| 180 |
+
# Generation is the wall-clock bottleneck, not training -- use every core.
|
| 181 |
+
echo "=== generating $PAIRS pairs on $CORES cores ==="
|
| 182 |
+
# --workers 0 detects the cgroup quota. --augment takes several states from each
|
| 183 |
+
# solution path, so one Kociemba solve yields several pairs: an ~8x cut in solves,
|
| 184 |
+
# which is the actual bottleneck. It also samples distance-to-solved far more
|
| 185 |
+
# evenly than uniform states do (those are ~95% distance-18-to-20), which happens
|
| 186 |
+
# to match a benchmark whose buckets run from depth 3 upward.
|
| 187 |
+
/opt/venv/bin/python gen_data.py --count "$PAIRS" --workers 0 --augment 8 --out data/train.jsonl
|
| 188 |
+
|
| 189 |
+
# A separate seed, and canonical labels only: the holdout must reflect the real
|
| 190 |
+
# task, not the augmented training mix, or the reported solve rate is inflated.
|
| 191 |
+
echo "=== generating canonical holdout ==="
|
| 192 |
+
# Holdout stays unaugmented and uniform: the hard case. Scoring against the
|
| 193 |
+
# augmented mix would flatter the model, since those states sit partway along a
|
| 194 |
+
# solution path and are nearer to solved.
|
| 195 |
+
# --shards 32 fixes the shard count so the holdout is byte-identical on every
|
| 196 |
+
# machine. Without it the per-shard seeds follow the core count, so two boxes
|
| 197 |
+
# comparing two models evaluate them against merely similar holdouts rather than
|
| 198 |
+
# the same one -- silently downgrading a paired comparison to an unpaired one,
|
| 199 |
+
# exactly where the pairing matters most.
|
| 200 |
+
/opt/venv/bin/python gen_data.py --count 20000 --workers 0 --shards 32 --seed 999999 \
|
| 201 |
+
--augment 0 --out data/val.jsonl
|
| 202 |
+
|
| 203 |
+
# DAgger phase: if ROLLOUT_FROM names a checkpoint, roll that policy out, label
|
| 204 |
+
# the states it actually reaches, and append them to the training set.
|
| 205 |
+
#
|
| 206 |
+
# The model is otherwise trained only on states from the sampler and never on the
|
| 207 |
+
# ones its own attempts produce -- which is where it measurably fails, walking
|
| 208 |
+
# from 10 moves out to 18 and staying there. Behaviour cloning alone compounds
|
| 209 |
+
# error quadratically in horizon; training on the policy's own state
|
| 210 |
+
# distribution is the standard fix.
|
| 211 |
+
if [ -n "${ROLLOUT_FROM:-}" ]; then
|
| 212 |
+
echo "=== rolling out ${ROLLOUT_FROM} for ${ROLLOUT_PAIRS:-2000000} states ==="
|
| 213 |
+
/opt/venv/bin/python gen_rollout_data.py --checkpoint "$ROLLOUT_FROM" \
|
| 214 |
+
--count "${ROLLOUT_PAIRS:-2000000}" --batch-size "${ROLLOUT_BATCH:-2048}" --rounds 4 \
|
| 215 |
+
--out data/rollout.jsonl
|
| 216 |
+
/opt/venv/bin/python verify_data.py data/rollout.jsonl --limit 20000
|
| 217 |
+
cat data/rollout.jsonl >> data/train.jsonl
|
| 218 |
+
echo "=== training set is now $(wc -l < data/train.jsonl) pairs ==="
|
| 219 |
+
fi
|
| 220 |
+
|
| 221 |
+
echo "=== verifying (must report 0 FAILED) ==="
|
| 222 |
+
/opt/venv/bin/python verify_data.py data/train.jsonl --limit 100000
|
| 223 |
+
/opt/venv/bin/python verify_data.py data/val.jsonl
|
| 224 |
+
|
| 225 |
+
# TRAIN_VALUE switches the objective from "emit a solution" to "estimate distance
|
| 226 |
+
# to solved". The policy shape has been shown four times over not to reach past
|
| 227 |
+
# ~8 moves; this is the heuristic that lets search steer. Same data either way --
|
| 228 |
+
# the label is just the solution length instead of the solution.
|
| 229 |
+
|
| 230 |
+
if [ -n "${TRAIN_VALUE:-}" ]; then
|
| 231 |
+
echo "=== training value function ==="
|
| 232 |
+
/opt/venv/bin/python train_value.py \
|
| 233 |
+
--data data/train.jsonl --val-data data/val.jsonl \
|
| 234 |
+
--hidden "$HIDDEN" --layers "$LAYERS" --heads 8 \
|
| 235 |
+
--batch-size "${BATCH:-1024}" --lr 6e-4 --max-steps "$STEPS" \
|
| 236 |
+
--eval-every 1000 --eval-n 8192 \
|
| 237 |
+
--out checkpoints/value --hub-repo "${HUB_REPO:-}"
|
| 238 |
+
echo "=== done ==="
|
| 239 |
+
exit 0
|
| 240 |
+
fi
|
| 241 |
+
|
| 242 |
+
echo "=== training ==="
|
| 243 |
+
/opt/venv/bin/python train.py \
|
| 244 |
+
--data data/train.jsonl --val-data data/val.jsonl \
|
| 245 |
+
--hidden "$HIDDEN" --layers "$LAYERS" --heads 8 \
|
| 246 |
+
--batch-size "${BATCH:-1024}" --lr 6e-4 --max-steps "$STEPS" \
|
| 247 |
+
--eval-every 1000 --eval-n 512 \
|
| 248 |
+
--out checkpoints/cube --hub-repo "${HUB_REPO:-}" ${RESUME:+--resume "$RESUME"}
|
| 249 |
+
|
| 250 |
+
echo "=== done ==="
|
code/verify_data.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Checks a generated dataset by replaying it: applies each recorded solution to its
|
| 3 |
+
recorded state and confirms the cube ends solved.
|
| 4 |
+
|
| 5 |
+
This is the one check worth running on every dataset before training on it. A
|
| 6 |
+
generator bug that corrupts states or mislabels them is silent otherwise -- the
|
| 7 |
+
model simply learns the wrong function and the failure only surfaces much later,
|
| 8 |
+
as a bad benchmark number that looks like a modelling problem.
|
| 9 |
+
"""
|
| 10 |
+
import argparse, json, sys
|
| 11 |
+
from gen_data import SOLVED, apply_sequence
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main():
|
| 15 |
+
p = argparse.ArgumentParser(description=__doc__)
|
| 16 |
+
p.add_argument("path")
|
| 17 |
+
p.add_argument("--limit", type=int, default=0, help="Check only the first N rows (0 = all).")
|
| 18 |
+
args = p.parse_args()
|
| 19 |
+
|
| 20 |
+
checked = failed = 0
|
| 21 |
+
lengths = []
|
| 22 |
+
with open(args.path) as fh:
|
| 23 |
+
for line in fh:
|
| 24 |
+
if args.limit and checked >= args.limit:
|
| 25 |
+
break
|
| 26 |
+
row = json.loads(line)
|
| 27 |
+
if len(row["state"]) != 54:
|
| 28 |
+
failed += 1
|
| 29 |
+
continue
|
| 30 |
+
moves = row["solution"].split()
|
| 31 |
+
lengths.append(len(moves))
|
| 32 |
+
if apply_sequence(row["state"], moves) != SOLVED:
|
| 33 |
+
failed += 1
|
| 34 |
+
checked += 1
|
| 35 |
+
|
| 36 |
+
mean = sum(lengths) / len(lengths) if lengths else 0
|
| 37 |
+
print(f"checked {checked}, solved {checked - failed}, FAILED {failed}")
|
| 38 |
+
print(f"solution length: mean {mean:.2f} min {min(lengths, default=0)} max {max(lengths, default=0)}")
|
| 39 |
+
sys.exit(1 if failed else 0)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
if __name__ == "__main__":
|
| 43 |
+
main()
|