briscoooe commited on
Commit
9eb2e79
·
verified ·
1 Parent(s): 8a8bff5

Upload folder using huggingface_hub

Browse files
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()